asterism branch T-2052/background-export commits 16 unpushed files 37 touched lines +4,516 / -71 tests test-core ✓ · test-quick 1079 ✓ · verify-identity ✓

Pre-push review: T-2052/background-export

Background export (T-2052): an iOS app-refresh pass that lets the app's CloudKit mirror export captures the share extension committed with mirroring off, keyed on a marker directory and an export-start settlement rule. Full spec, four phases, reviewed by four agents; every finding either fixed in f3f7f29 or skipped with a reason.

At a glance

  • What it does. The extension leaves one empty UUID file per commit in ExportOwed/; a background grant lists them, settles any older than the persisted last export start, opens (or reuses) the library, waits at most 20 s for an export that started after the newest marker, clears exactly the names it listed, releases.
  • Where the logic lives. All of it in AsterismCore (BackgroundExportPass, ExportOwedMarker, SyncMonitor.awaitExport); the app supplies only the library session and the scheduling.
  • Exclusion. bootstrap() and the cold acquire() are the only openers, both main-actor; bootstrapInFlight is set before the first suspension and a pass in flight is cancelled and drained, so the foreground pre-empts in milliseconds and two mirrored containers are never open at once (asserted as a high-water mark of one).
  • Review outcome. 3 major, 9 minor and 12 nit findings raised; 15 fixed across f3f7f29 and da96b41, 8 skipped as deliberate per design or needing test edits.
  • Not proven on the host. The real grant, the resident export-on-resume (Q29), expiry, the protection class, and the second-install arrival: eight runbook steps, Development first, Personal last after a container download.

Verdict

Ready to push (after rebase)

Every acceptance criterion is implemented as specified or correctly deferred to the device runbook; the three major review findings (a double refresh on activation, a deferral flag never cleared, a verbatim copy of the plist-reading ladder) are fixed and the suites are green on the final state. Two things stand between this and a PR: origin/main moved (T-2304, #56) after the branch was cut, so rebase first; and the runbook is the owner's device run, not a merge blocker.

Review findings

23 raised · 15 fixed · 8 skipped

Jump to findings →

Commits

Three-level explanation

Beginner Level

What Changed / What This Does

When you share a page to Asterism from Safari on your phone, the share extension saves it into the app's library on that phone. Until now, that page only travelled to iCloud (and so to your other devices) the next time you opened the Asterism app itself, because only the app is allowed to talk to iCloud. If you shared three pages during the day and never opened the app, none of them left the phone.

This change teaches the app to ask iOS for a little background time whenever it is not on screen. When iOS grants that time, the app checks a small "to-do" folder that the share extension writes into after every save. If the folder is empty, the app does nothing and gives the time back. If there is something in it, the app opens its library just long enough for iCloud to send the new pages, watches for that send to happen, and then closes the library again. The whole thing has to finish within 20 seconds.

Why It Matters

Sharing becomes a one-gesture act. Share on the phone, and the page appears on the iPad or the Mac a little later without the phone's app ever being opened. Nothing about how pages are saved changes, and nothing is lost if iOS never grants the time: the page still goes across the next time the app is opened, exactly as before.

Key Concepts

  • Share extension. A tiny separate program that runs inside the share sheet. It can write into the library but is deliberately not allowed to sync with iCloud (it would use too much memory and would fight the app over the same files).
  • CloudKit mirror. The part of the app that copies library changes up to iCloud ("export") and pulls other devices' changes down ("import"). It only runs while the app has the library open.
  • Background app refresh. iOS's mechanism for waking an app briefly while it is not on screen. The app asks; iOS decides when, and may say no (Low Power Mode, Background App Refresh switched off).
  • The marker. One empty file, named with a random ID, that the extension creates after each successful save. Think of it as a sticky note saying "something new is waiting to be sent". The app removes the note only after it has seen a send that started after the note was written.
  • A pass. One run of the background work: read the notes, open the library if needed, wait for a send, remove the notes it saw, close the library.

Intermediate Level

Changes Overview

Package (AsterismCore):

  • ExportOwedMarker.swift (new): the App Group directory ExportOwed/. mark() creates an empty UUID-named file, pending() lists entries with creation dates (missing directory reads as empty, any other listing error rethrows), clear(_:) unlinks by name and ignores ENOENT. Directory and files are created on the completeUntilFirstUserAuthentication protection class.
  • SyncMonitor.swift: SyncEvent.startDate, SyncStatusRecord.lastExportStarted (persisted; updated only on a successful export and only forward), awaitExport(startedAfter:deadline:) (a cancellable wait resolving .exported, .failed, .deadline, .cancelled or .stopped), and observe(_:) as the public spelling of ingest.
  • BackgroundExportPass.swift (new): the pass, its outcomes, the 20 s budget, the BackgroundExportSession protocol the app implements, the log, and three statics: settle (persisted half of the rule), awaitExportAndClear (live half), reportUnstarted (log lines for a grant turned away before a pass exists).
  • LibraryProviding.isBulkOperationInProgress() (default false; the repository actor answers its Q46 flag).
  • LibraryConfiguration: exportOwedURL, and a public declaredIdentifier(forKey:operation:in:) so the scheduler reads its plist key through the same ladder as the App Group key.
  • Boundaries.swift: Duration.timeInterval.

App:

  • AppLibraryModel: runBackgroundExport(), the BackgroundExportSession conformance (acquire, release, isPreempting), the exclusion protocol with bootstrap(), the arrivals gate, and armExportOwedClearing() for the foreground.
  • BackgroundExportScheduler (iOS only): submits the BGAppRefreshTaskRequest, logs a refusal once per process.
  • AsterismApp: .backgroundTask(.appRefresh(...)) handler and .onChange(of: scenePhase) submit.
  • SettingsView / BackgroundExportTriggerModel (#if DEBUG): a "Run background export" row that calls the same method as the handler.
  • Info.plist / project.pbxproj / verify-identity.sh: the task identifier $(ASTERISM_IDENTITY).backgroundExport, BGTaskSchedulerPermittedIdentifiers, fetch in UIBackgroundModes, and the lint that pins all three.

Extension: ShareCaptureSession builds an ExportOwedMarker beside the spool and calls mark() after a committed capture's spool record is discarded and before the extension completes.

Implementation Approach

The settlement rule is the heart of it. A marker is settled by any successful export whose start is later than the marker's creation. It is applied twice: once against the persisted lastExportStarted at listing time (so a marker whose export ran while nobody was watching is cleared with no library open), and once live through awaitExport with the newest outstanding marker's creation date as the threshold. Clearing is by name, so a marker written after the listing survives whatever the pass does.

The pass lives in the package and asks the app for a library session through a protocol. That is what makes every decision (skip, settle, wait, clear, release) provable with a fake session under make test-core. The app supplies only acquire() (reuse the resident repository and monitor, or open one of its own on a cold launch), release() and isPreempting.

Exclusion between openers. bootstrap() and the pass's cold acquire() are the only two things that open the store, both main-actor methods on the model. bootstrap() sets bootstrapInFlight before its first suspension and drains any pass in flight (cancel() then await value, in a loop); the pass reads the flag before it creates its task and again inside acquire(). A foreground open therefore pre-empts a pass in milliseconds rather than waiting out the budget, and the pass reports .preempted rather than .expired.

Cancellation. Awaiting a stored Task's value does not propagate the awaiting task's cancellation, so every caller joins the shared pass through withTaskCancellationHandler, and awaitExport registers its waiter inside withCheckedContinuation with a cancellation hop that resolves the waiter on the main actor. One resolver removes the waiter before resuming it, so an event, the deadline sleeper, stop() and the cancel hop cannot resume a continuation twice.

Scheduling. One app-refresh request, no earliest date, submitted before and after each pass and whenever a scene goes to the background. iOS replaces a pending request with the same identifier, so all three submit sites are idempotent.

Trade-offs

  • Refresh grant, not processing. Refresh grants arrive during the day; processing grants arrive overnight on a charger, which defeats the latency goal. The cost is a hard 20 s budget: a large export on a slow network waits for the next grant or the next foreground open (Decision 2).
  • The pass is a mode of the model, and the foreground pre-empts it. The alternative (foreground waits) could show the reader a spinner for 20 s; the other alternative (foreground adopts the pass's repository) would need a second bootstrap entry point. One extra open in a rare overlap is cheaper (Decision 1).
  • Best-effort settlement. An export enqueued before a commit can start after the marker and succeed without carrying it. The marker is then gone but the mirror's own history token still precedes the commit, so the capture goes with the next export. The cost is the pre-feature latency for one capture, never a lost or duplicated one (Q31).
  • #if DEBUG for the trigger, not #if os(iOS) && DEBUG. The platform-seam test confines os( conditionals to four files. A Development Mac gets the row too; the pass is platform-neutral (Q34).

Expert Level

Technical Deep Dive

Waiter shape in awaitExport. Waiters are id-keyed structs in a main-actor array: (id, threshold, continuation, deadlineTask). Registration happens inside the withCheckedContinuation closure after a Task.isCancelled check, so a task already cancelled when the call is made resumes .cancelled without registering (otherwise onCancel has already run and nothing would ever resume it). The deadline sleeper is built first and appended with the waiter in one step; it goes through the injected sleeper, so the deadline case tests through the fake clock with no wall time. onCancel is non-isolated and only hops: Task { @MainActor in resolve(id, .cancelled) }. stop() drains the list with .stopped, which the pass maps to .preempted because the only thing that stops a monitor under a pass is the foreground tearing the library down.

Threshold semantics. settle clears createdAt < lastExportStarted; awaitExport resolves on startDate > threshold. Both are strict in the same direction, so a marker whose creation equals an export's start is neither settled nor matched. lastExportStarted moves only forward and only on succeeded == true export events; an out-of-order event cannot un-settle markers an export has already taken. SyncEvent.startDate is optional because the framework may report an event without one; such events are ignored by both halves.

Run order in the pass. t0pending() (throw → .skipped(.unavailable), never .noMarker) → settle → log start → acquire()Task.isCancelled check → awaitExportAndClear(outstanding, deadline: t0 + budget) → map the wait result → release() → log end. The budget is on the repository clock from t0, not from acquire() returning, so a slow open eats into the wait rather than extending the grant. release() runs on every path after a successful acquire() and before the outcome is reported (Req 1.5).

Cold path. The SyncMonitor is built and started before openForApp (Q26): the monitor observes notifications only and the certification container emits none, so starting early is free, while starting after the open could miss a setup or export event that completed during it. openForApp runs unchanged, so certification, its repairs and work-type seeding are the pass's only writes (Req 1.11). CancellationError from the cross-process lock wait maps to .cancelled, everything else to .skipped(.unavailable(reason)), and every failing arm stops the monitor and shuts down whatever opened. The session is held in backgroundSession, never repository or state, so a scene-less pass publishes nothing the foreground reads. The configuration is resolved once per pass and handed to the cold arm through passConfiguration.

Resident path. acquire() order is bootstrapInFlightstate == .unavailablestate == .ready && repository != nil (bulk check, monitor nil check, set backgroundPassHoldsLibrary) → cold. bootstrapInFlight dominates because bootstrap() sets state = .loading before its first suspension, so the two cannot both hold. While backgroundPassHoldsLibrary is set, handleSyncArrivals() records arrivalsDeferredByPass and returns; drainAndReconcile() runs the deferred reconcile, then the drain, then one refresh (the review fixed a double refresh here), and an ordinary arrival after release clears the flag.

What makes the resident mirror export is Core Data's cross-process remote-change notification, coalesced and delivered when the suspended process resumes. A background grant is a resume. The design commits to this with the runbook as the proof; a timed out on the resident step sends that row back to design rather than being patched (Q29).

Foreground clearing (armExportOwedClearing) applies the same two halves against the resident monitor with no deadline, parked in a stored task that the next arm or teardownRepository() cancels. It is armed at the end of bootstrap() and on every activation, not on every remote change (Q23). It compiles on every platform and is the whole of the feature on the Mac (Q16).

Identity. The task identifier is one project-level setting, $(ASTERISM_IDENTITY).backgroundExport, referenced from the app plist twice (scalar key and the single element of BGTaskSchedulerPermittedIdentifiers). verify-identity.sh pins the derivation, checks both references, checks fetch in UIBackgroundModes, asserts both keys absent from the extension plists, adds the composed literals to the literal sweep, and (Q37) adds the setting to identity_keys_in() so the shadow check covers it.

Architecture Impact

  • AppLibraryModel now has a second lifecycle (backgroundSession, backgroundExportTask, exportOwedClearingTask) beside the foreground one, and bootstrap() pays a pre-emption step on every call. The single-container invariant (Req 1.8) is only provable because both openers are serialised through this one main-actor owner; a third opener would have to join the same protocol.
  • SyncMonitor carries two waiter lists with different disciplines. awaitQuiescence has no cancellation handling and is deliberately not the template for awaitExport.
  • SyncStatusRecord gains a field with currentVersion unchanged: a record written before this build decodes with lastExportStarted == nil, and one written after is ignored by an older build.
  • The extension gains one obligation and no new capability; it still opens with mirroring off.
  • The Mac compiles the marker write and the foreground arm and nothing else; BackgroundTasks does not exist there.

Potential Issues

  • Early settlement (Q31): an export enqueued before a commit that starts after the marker; or a device clock stepped backwards between commit and export. Bounded to one capture's pre-feature latency. The runbook's two-device step watches for it.
  • Two overlapping bootstrap() calls on a multi-scene iPad are a pre-existing hazard this feature neither fixes nor worsens (Q28).
  • A marker whose creation date cannot be read falls back to .distantPast and settles on the first persisted export start. APFS always records creation dates, so this is theoretical; the alternative (.distantFuture) would hold every pass at its deadline forever.
  • Markers accumulate while no export ever succeeds (no iCloud account for weeks). Empty files, bounded by the number of captures; nothing prunes them until an export succeeds (Decision 3).
  • The unstarted arms (bootstrapInFlight, unresolvable configuration) log through reportUnstarted so a grant landing mid-bootstrap is visible in Console (Q40).
  • BGTaskScheduler.submit(_:) is not deprecated on the iOS 26 SDK; if a later SDK deprecates it, BackgroundExportScheduler is the one place to change.
  • Nothing on the host reaches the extension's finish; the marker write's position and its log-and-ignore wrapper are verified by inspection and by the runbook's first two steps.

Completeness Assessment

Fully implemented and host-tested

  • Req 1.2–1.11 (pass semantics, budget, release, reuse, pre-emption, exclusion, unavailable store, bulk operation, no writes beyond the open path) — BackgroundExportPassTests, AppLibraryModelBackgroundExportTests, SyncMonitorTests.
  • Req 2.1–2.6 (marker, no-marker skip, compare-and-clear, foreground clearing, failed write tolerated, protected container reads as unavailable) — ExportOwedMarkerTests, the pass tests, the model's clearing tests; 2.5's call site by inspection.
  • Req 3.1–3.4 (request pending, no external power, refusal logged once, per-configuration declarations) — BackgroundExportSchedulerTests, verify-identity.sh and its recorded self-test.
  • Req 4.1–4.2 (two log lines per pass, Development-only trigger) — pass tests, BackgroundExportSettingsUITests.

Implemented, verifiable only on device (runbook)

  • Req 1.1 second-install arrival; Req 1.4's system expiry; Req 1.6's resident export on resume (Q29); Req 3.5 cold launch on a real grant; the marker's protection class (Q18); the refusal line with Background App Refresh off.

Not implemented

  • Nothing in scope is missing. The runbook has not been run; that is the user's device run and needs approval at the moment of running.

Divergences from the design, all recorded

Q32–Q40 in the decision log: the log line's shape and the failed message's source; #if DEBUG alone; the closure-wired trigger and the dropped factory; the extra test seams; the fifth lint addition; the AsterismApp modifier placement and the platform-seam list split; the test file location; unstarted grants logging.

Important changes — detailed

SyncMonitor: awaitExport and the export-start rule

Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swift

Why it matters. The live half of the settlement rule and the only cancellable wait in the monitor; a mistake here either hangs a grant to expiry or resumes a continuation twice.

What to look at. SyncMonitor.swift:432-497 (awaitExport, resolveExportWaiters, resolveExportWaiter); :245-253 (lastExportStarted only forward)

Takeaway. Register the waiter inside withCheckedContinuation after a Task.isCancelled check, let onCancel only hop to the actor, and have one resolver that removes-then-resumes. That shape survives an event, a deadline, stop() and cancellation racing.
Rationale. Awaiting a stored Task does not propagate the awaiting task's cancellation (Q30); awaitQuiescence has no cancellation handling and was rejected as the template.

BackgroundExportPass: run order, settle, awaitExportAndClear

Packages/AsterismCore/Sources/AsterismCore/BackgroundExportPass.swift

Why it matters. Every decision the grant makes, in the package so make test-core proves it against a fake session. Clearing only the listed names is what keeps a capture committed mid-pass from being stranded.

What to look at. BackgroundExportPass.swift:188-283 (run), :285-341 (settle, awaitExportAndClear), :343 onward (reportUnstarted)

Takeaway. Compare-and-clear by identity: list, remember names, act, unlink exactly those names. A name created after the listing is not in the set and survives with no lock.
Rationale. Decision 3 and Q8: a timestamp compared against an export's end is unsafe; the rule is on the export's start and the marker's identity.

AppLibraryModel: the session, exclusion, pre-emption, arrivals gate

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. The single-container invariant (Req 1.8) is only provable because both openers go through this one main-actor owner; the reentrancy at every await is the hazard.

What to look at. AppLibraryModel.swift:288-302 (bootstrap flag and drain loop), :1373-1428 (runBackgroundExport, joining), :1430-1520 (acquire, acquireColdSession, release), :1672-1710 (handleSyncArrivals, reconcileArrivals)

Takeaway. On a reentrant actor, set the exclusion flag synchronously before the first await and drain the other party in a loop, not a single await: a joiner can take the handle across the suspension.
Rationale. Decision 1: pre-empt rather than wait so the reader never sees a 20 s spinner; the foreground mirror exports the same changes anyway.

Foreground clearing: armExportOwedClearing

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. Without it every grant after ordinary use opens the library for nothing, since the foreground mirror already exported. It is also the Mac's entire share of the feature.

What to look at. AppLibraryModel.swift:1544-1580

Takeaway. The persisted half of a rule can be applied at listing time with no wait; only what it leaves needs the live half.
Rationale. Q9, Q23: armed at bootstrap end and every activation, not per remote change, to avoid listing the directory on every hydration transaction.

Scheduler, handler and identity lint

Asterism/Asterism/Support/BackgroundExportScheduler.swift

Why it matters. A wrong identifier is a request iOS silently never grants; the lint is what makes the declaration chain fail loudly at build time instead.

What to look at. BackgroundExportScheduler.swift:41-111; AsterismApp.swift:116-127; scripts/verify-identity.sh (check_single_reference_array, the app and extension arms, identity_keys_in)

Takeaway. Declare an identifier once as a build setting, reference it from the plist, and lint every reference, including asserting absence where it must not appear.
Rationale. Q22 and Q37; the submit-before-and-after-the-pass shape is Req 3.1 for a cold launch that never reaches scenePhase == .background.

Extension: the marker write after a durable commit

Asterism/AsterismShareExtension/ShareCaptureSession.swift

Why it matters. Position is the precondition of the whole rule: written after the commit is durable, never before, or an export that began before the commit could settle it.

What to look at. ShareCaptureSession.swift:73, :176, :236-250

Takeaway. When a marker stands for "a commit at least this old", write it after the commit and say so in a comment nobody can move it past.
Rationale. Design section The marker; Req 2.5 makes the failed write a logged no-op so the capture always completes.

Review fix: one refresh per activation after a deferred arrival

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. The activation path is the app's most latency-sensitive one and already carries a known perf breach; the first cut refreshed diagnoses and snapshots twice whenever a pass had deferred arrivals.

What to look at. AppLibraryModel.swift:503-530 (drainAndReconcile), :1700 (reconcileArrivals)

Takeaway. When a deferred handler ends in the same refresh the caller is about to run, split the handler so the caller owns the single refresh.
Rationale. Found by the efficiency review; the fix keeps reconcileAfterSyncCallCount behaviour identical and gates the duplicate follow-up on the deferral. (inferred — not stated by the author)

Key decisions

The pass is a mode of AppLibraryModel and the foreground pre-empts it (Decision 1).

One owner, one actor, two openers that exclude each other by construction. A standalone Core opener could not reuse a live library and could collide with bootstrap(); making the foreground wait could show a 20 s spinner.

One app-refresh task, no processing task, 20 s budget (Decision 2, Q20).

Refresh grants arrive during the day; processing grants arrive overnight on a charger. The open is budgeted at 2 s plus up to the 5 s lock timeout, so 20 s of a roughly 30 s grant leaves margin for shutdown.

Marker as a directory of empty UUID files (Decision 3).

Create and unlink of distinct names are atomic, so compare-and-clear needs no lock and no content. A single token file would lose an extension write between compare and unlink.

Settle on the export's start against the marker's creation; persist the last successful start (Q24).

Keying on the pass's own listing time never cleared a marker whose export ran before anyone watched. Both stamps come from the device clock; a backwards step settles early at the cost of one capture's latency.

Best-effort settlement (Q31).

An export's start date is the activity's start, not a history fence; an export enqueued before a commit can start after the marker. Bounded to the pre-feature latency for one capture. The runbook watches for it.

The Development trigger is <code>#if DEBUG</code> alone (Q34).

ipad-and-mac-layouts Req 4.5 confines #if os( to four files and PlatformSeamTests enforces it. A Development Mac shows the row; the pass is platform-neutral.

Modifiers unguarded in body's #else branch; platform-seam list split (Q38).

Swift rejects a postfix #if nested inside that #else. SUPPORTED_PLATFORMS is iOS plus macOS, so not-macOS is iOS and a fourth platform fails loudly on the properties.

Unstarted grants still log two lines (Q40).

Pre-push review: Req 4.1 and Q32 promise two lines per pass and the runbook reads on that assumption.

Skipped review suggestions.
  • Delete the post-acquire() cancellation check: the design names it and the test asserts no waiter registered.
  • Collapse assert plus defensive release() in bootstrap(): the design specifies both.
  • Replace BackgroundExportTriggerModel with view state: the design names the type; marginal gain.
  • BackgroundExportLogLine as an enum, a PassHold enum, loggedRefusals as Bool, SyncEvent.init parameter order: would need test edits or are shape preferences.
  • Fold the sleep-based join barrier in one app test: test file; low blast radius (fails rather than passes wrongly).
  • Convert Info.plist once in the lint instead of per-key plutil: consistent with existing checks; about 0.2 s.

Review findings

SeverityAreaFindingResolution
majorAppLibraryModel.drainAndReconcileDiagnosis and snapshot refresh ran twice per activation whenever a pass had deferred arrivals (handleSyncArrivals ends in the refresh the caller then repeats).Deferred arrival reconciles via reconcileArrivals(), then the drain, then one refresh, then the follow-up gated on the deferral.
majorAppLibraryModel.handleSyncArrivalsarrivalsDeferredByPass was only cleared in drainAndReconcile, so an ordinary arrival after release left it set and the next activation re-reconciled.Cleared at the top of the non-deferred branch; field made private.
majorBackgroundExportScheduler.declaredTaskIdentifierVerbatim re-implementation of LibraryConfiguration.declaredIdentifier's five-guard ladder and messages.Core helper promoted to public declaredIdentifier(forKey:operation:in:); scheduler is a do/try/catch that traps with the guidance sentence.
minorDuration to secondsThree copies of the components.seconds plus attoseconds arithmetic in one module.One internal Duration.timeInterval in Boundaries.swift.
minorAppLibraryModel configuration resolutionResolved twice per cold pass with two identical try/catch ladders; each is a containerURL(forSecurityApplicationGroupIdentifier:) call inside the grant budget.configurationForPass() once; handed to the cold arm through passConfiguration.
minorarmExportOwedClearing vs the passThe threshold, awaitExport, clear(names) half was stated twice, one copy outside make test-core's reach.BackgroundExportPass.awaitExportAndClear shared by both arms.
minorrunBackgroundExport early returnsA grant turned away mid-bootstrap or on an unresolvable configuration logged nothing, contradicting Req 4.1 / Q32 and the runbook.BackgroundExportPass.reportUnstarted emits the pair; Q40 recorded.
minorverify-identity.shcheck_permitted_identifiers was check_entitlements_array with different sentences.check_single_reference_array with two thin wrappers; both self-tests re-run with identical messages.
nitSyncMonitor.awaitExportUnreachable else branch cancelling the sleeper after firstIndex(where:): registration is synchronous on the actor.Sleeper built first, waiter appended with it.
nitrunBackgroundExport taskA weak self capture that cannot fire, plus a minted skip sentence for it.Strong capture; sentence removed.
nitShareCaptureSession.resolveThe pure resolver closure gained a weak-self side effect.Marker set from the resolved local after flow.run().
nitLibraryRepository doc commentsisBulkOperationInProgress() inserted under other methods' comments; armDuplicateFollowUp left undocumented (pre-existing misalignment compounded).Method moved; the two stacked paragraphs split onto their declarations.
nitExportOwedMarker.createDirectoryIfNeededfileExists guard before createDirectory(withIntermediateDirectories:): pure cost, TOCTOU window.Guard dropped.
nitBackgroundExportPass.settleTwo complementary filters that must stay exact complements.One partition loop.
nitCHANGELOG and decision log"twelve" SyncMonitorTests cases (eleven); test-file location not recorded.Corrected; Q39 added.
minorBackgroundExportPass post-acquire cancellation checkDuplicates what awaitExport's own Task.isCancelled guard does.Kept: the design names the check and the test asserts pendingExportWaiterCount == 0 without relying on the monitor's internals.
minorSettingsView trigger trioClosure plus @Observable model plus optional @State for one debug button.Kept: BackgroundExportTriggerModel is the design's named type; the closure is what keeps the parameter unconditional (Q35).
minorPublic test seamspendingExportWaiterCount and isObserving on SyncMonitor; private(set) fields on the model.Kept and recorded in Q36; the app bundle imports Core without @testable.
nitbootstrap() assert vs defensive releaseDevelopment traps on a condition Personal silently repairs.Kept: the design specifies both.
nitShape suggestionsLogLine as enum, PassHold enum, loggedRefusals as Bool, SyncEvent.init order.Skipped: would require test edits or are preference-level.
minorconcurrentCallersShareOnePass test20 ms Task.sleep as a join barrier.Skipped (test file); fails rather than passes wrongly on a slow host.
minorverify-identity plutil spawnsPer-key plutil calls on every make test-core.Skipped: matches the existing per-key checks; about 0.2 s.
nittasks.md Blocked-by linesStray tokens (session, emption, trigger, handler) from rune splitting on parenthesised titles.Skipped: rune parses cleanly and every task is done; cosmetic.

Per-file diffs

Click to expand.

Asterism/Asterism.xcodeproj/project.pbxproj Modified +2 / -0
diff --git a/Asterism/Asterism.xcodeproj/project.pbxproj b/Asterism/Asterism.xcodeproj/project.pbxprojindex 308050d..8e88f5a 100644--- a/Asterism/Asterism.xcodeproj/project.pbxproj+++ b/Asterism/Asterism.xcodeproj/project.pbxproj@@ -797,6 +797,7 @@ 				ALWAYS_SEARCH_USER_PATHS = NO; 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 				ASTERISM_APP_GROUP_IDENTIFIER = "group.$(ASTERISM_IDENTITY)";+				ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER = "$(ASTERISM_IDENTITY).backgroundExport"; 				ASTERISM_EXTENSION_DISPLAY_NAME = "Asterism Dev"; 				ASTERISM_ICLOUD_CONTAINER_IDENTIFIER = "iCloud.$(ASTERISM_IDENTITY)"; 				ASTERISM_IDENTITY = me.nore.ig.Asterism.dev;@@ -866,6 +867,7 @@ 				ALWAYS_SEARCH_USER_PATHS = NO; 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 				ASTERISM_APP_GROUP_IDENTIFIER = "group.$(ASTERISM_IDENTITY)";+				ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER = "$(ASTERISM_IDENTITY).backgroundExport"; 				ASTERISM_EXTENSION_DISPLAY_NAME = Asterism; 				ASTERISM_ICLOUD_CONTAINER_IDENTIFIER = "iCloud.$(ASTERISM_IDENTITY)"; 				ASTERISM_IDENTITY = me.nore.ig.Asterism;
Asterism/Asterism/AsterismApp.swift Modified +46 / -0
diff --git a/Asterism/Asterism/AsterismApp.swift b/Asterism/Asterism/AsterismApp.swiftindex 19c7279..171f0c2 100644--- a/Asterism/Asterism/AsterismApp.swift+++ b/Asterism/Asterism/AsterismApp.swift@@ -30,6 +30,24 @@ struct AsterismApp: App {     /// place, so the state has to outlive whichever of them is on screen.     @State private var navigation = AppNavigation() +    #if os(iOS)+    /// Watched only to know when the app has left the foreground, which is one+    /// of the two places a refresh request is submitted (Q21).+    @Environment(\.scenePhase) private var scenePhase++    /// The one scheduler in the process, so its once-per-launch refusal log is+    /// once per launch (Req 3.3). `BackgroundTasks` exists on iOS alone; the+    /// Mac's share of this feature is the foreground marker clearing that+    /// `AppLibraryModel` arms on every platform (Q16).+    ///+    /// Both properties are used from `body`'s `#else` branch rather than from a+    /// third conditional inside the modifier chain: the app target's+    /// `SUPPORTED_PLATFORMS` is `iphoneos iphonesimulator macosx`, so not-macOS+    /// is iOS. A fourth platform would fail to compile here — loudly, which is+    /// the right way for it to fail — rather than silently drop the feature.+    private let scheduler = BackgroundExportScheduler()+    #endif+     init() {         #if canImport(UIKit)         Self.applySerifNavigationTitles()@@ -78,6 +96,34 @@ struct AsterismApp: App {         // there would remove the system's own to put the same one back.         .commands { AsterismCommands() }         .commands { AppSettingsCommands() }+        // The background export grant (Req 1.1, 3.5). The handler runs on a+        // cold launch with no scene connected as readily as on a resident wake,+        // which is why it hangs off the scene rather than off any view.+        //+        // `model` is captured at body evaluation on purpose: `AppLibraryModel`+        // is a class made once in this App's initialiser, so the instance a+        // scene-less launch hands the pass is the same one `ContentView`+        // installs when a scene later connects. The resident-reuse and+        // pre-emption paths (Decision 1) are only coherent because of that+        // identity.+        //+        // The request is resubmitted *before* the pass as well as after it: a+        // cold launch never reaches `scenePhase == .background`, so a process+        // the system kills at expiry would otherwise leave nothing pending+        // (Req 3.1). Both hops are explicit — the handler's closure is+        // `@Sendable` with no executor guarantee, and both callees are+        // main-actor.+        .backgroundTask(.appRefresh(BackgroundExportScheduler.identifier)) { [model, scheduler] in+            await scheduler.submit()+            _ = await model.runBackgroundExport()+            await scheduler.submit()+        }+        // The other submit site (Q21). Per-scene on a multi-window iPad, and+        // duplicate submissions are harmless: submitting replaces the pending+        // request carrying the same identifier.+        .onChange(of: scenePhase) { _, phase in+            if phase == .background { scheduler.submit() }+        }         #endif     } 
Asterism/Asterism/Info.plist Modified +7 / -0
diff --git a/Asterism/Asterism/Info.plist b/Asterism/Asterism/Info.plistindex ee11e4d..e321c57 100644--- a/Asterism/Asterism/Info.plist+++ b/Asterism/Asterism/Info.plist@@ -4,13 +4,20 @@ <dict> 	<key>AsterismAppGroupIdentifier</key> 	<string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string>+	<key>AsterismBackgroundExportTaskIdentifier</key>+	<string>$(ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER)</string> 	<key>AsterismCloudKitContainerIdentifier</key> 	<string>$(ASTERISM_ICLOUD_CONTAINER_IDENTIFIER)</string> 	<key>AsterismCloudKitMirroringEnabled</key> 	<string>$(ASTERISM_MIRRORING_ENABLED)</string>+	<key>BGTaskSchedulerPermittedIdentifiers</key>+	<array>+		<string>$(ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER)</string>+	</array> 	<key>UIBackgroundModes</key> 	<array> 		<string>remote-notification</string>+		<string>fetch</string> 	</array> </dict> </plist>
Asterism/Asterism/Layout/SettingsScreen.swift Modified +7 / -1
diff --git a/Asterism/Asterism/Layout/SettingsScreen.swift b/Asterism/Asterism/Layout/SettingsScreen.swiftindex 1e30382..8520113 100644--- a/Asterism/Asterism/Layout/SettingsScreen.swift+++ b/Asterism/Asterism/Layout/SettingsScreen.swift@@ -95,7 +95,13 @@ struct SettingsScreen: View {             onOpenDrainedEntry: { entryID in                 navigation.pendingRoute = .drainedEntry(entryID)                 leaveSettings()-            }+            },+            // Req 4.2's Development-only trigger. Handed over on every build:+            // the row that shows it is what the `#if DEBUG` gate withholds —+            // `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() }         )     } 
Asterism/Asterism/Support/BackgroundExportScheduler.swift Added +111 / -0
diff --git a/Asterism/Asterism/Support/BackgroundExportScheduler.swift b/Asterism/Asterism/Support/BackgroundExportScheduler.swiftnew file mode 100644index 0000000..394ed4e--- /dev/null+++ b/Asterism/Asterism/Support/BackgroundExportScheduler.swift@@ -0,0 +1,111 @@+#if os(iOS)+import AsterismCore+import BackgroundTasks+import Foundation+import os++/// Keeps one app-refresh request pending whenever the app is not in the+/// foreground (Req 3.1).+///+/// iOS-only in its entirety, and the only file in the app target that imports+/// `BackgroundTasks`: the framework does not exist on macOS, where this feature+/// is the foreground clearing arm alone (Q16). `AsterismApp` calls `submit()`+/// from three places — before and after each pass, and on+/// `scenePhase == .background` (Q21) — and the system replaces a pending+/// request carrying the same identifier, so all three are idempotent and "at+/// most one" comes free.+///+/// `submit()` is synchronous. `BGTaskScheduler.submit(_:)` is not deprecated on+/// the iOS 26 SDK (Decision 2); if a later SDK deprecates it, this class is the+/// one place that changes.+@MainActor+final class BackgroundExportScheduler {++    /// The bundle key carrying the task identifier.+    ///+    /// Its value is `$(ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER)`, a+    /// project-level build setting derived from `ASTERISM_IDENTITY` in each+    /// configuration, and the same reference is the single element of+    /// `BGTaskSchedulerPermittedIdentifiers` (Q22). `make verify-identity`+    /// lints all of that; nothing here restates the composed value.+    static let infoPlistKey = "AsterismBackgroundExportTaskIdentifier"++    /// The task identifier this build registers and submits.+    ///+    /// Traps rather than falling back, exactly as `declaredAppGroupIdentifier()`+    /// does and for the same reason (Q2): the value is derived at build time+    /// from the one identity declaration and the identity lint refuses to+    /// produce a product without it, so an absent or unexpanded value can only+    /// be a build defect. The alternative — guessing an identifier — is a build+    /// that submits a task the system will never grant, silently.+    static let identifier: String = declaredTaskIdentifier()++    private static let logger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "BackgroundExport")++    /// The seam. Injected so the refusal path is testable without a real+    /// scheduler, which refuses everything on a simulator anyway.+    private let submitter: (BGAppRefreshTaskRequest) throws -> Void++    /// How many refusals this scheduler has logged. One, at most (Req 3.3).+    ///+    /// The flag that makes the logging once-per-process, in a form a test can+    /// read. Instance-scoped, which is process-scoped in practice: `AsterismApp`+    /// holds exactly one scheduler.+    private(set) var loggedRefusals = 0++    init(submitter: @escaping (BGAppRefreshTaskRequest) throws -> Void = BGTaskScheduler.shared.submit) {+        self.submitter = submitter+    }++    /// Asks for the next refresh grant the system is willing to give.+    ///+    /// No `earliestBeginDate`: the feature exists to remove latency, so the+    /// request defers itself to nothing and lets iOS decide (Decision 2). An+    /// app-refresh request carries no external-power condition either, which is+    /// the whole of Req 3.2.+    ///+    /// A refusal — Background App Refresh switched off for the app, most often —+    /// is logged once and otherwise ignored: the feature goes inert and the+    /// foreground app is untouched (Req 3.3).+    func submit() {+        let request = BGAppRefreshTaskRequest(identifier: Self.identifier)+        do {+            try submitter(request)+        } catch {+            guard loggedRefusals == 0 else { return }+            loggedRefusals += 1+            Self.logger.error(+                """+                Background export refresh request refused: \+                \(error.localizedDescription, privacy: .public). \+                Captures will reach CloudKit at the next foreground open.+                """)+        }+    }++    /// The declared identifier, or a trap naming the break in the chain.+    ///+    /// The ladder itself — missing, not a string, empty, unexpanded — is+    /// `LibraryConfiguration.declaredIdentifier(forKey:operation:in:)`, the one+    /// every other identity key is read through. Only the *reaction* differs:+    /// the package throws, and this build cannot continue without the value, so+    /// the throw becomes the trap.+    private static func declaredTaskIdentifier() -> String {+        do {+            return try LibraryConfiguration.declaredIdentifier(+                forKey: infoPlistKey,+                operation: "resolving the declared background export task identifier",+                in: .main)+        } catch {+            fatalError(+                """+                Cannot resolve \(infoPlistKey) from this bundle: \(error)+                The key derives from ASTERISM_IDENTITY in the Xcode project; \+                run `make verify-identity` to find the break in the declaration chain.+                """+            )+        }+    }+}+#endif
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +394 / -23
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 0e382c0..e352da8 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -234,19 +234,26 @@ public final class AppLibraryModel {     /// refresh on demand — which is exactly the case Req 4.3 exists for, since     /// `refreshAll` swallows every error and a silently stale count is the     /// failure mode.+    ///+    /// `configuration` is what a background-export test needs on top of that: a+    /// pass reads its marker directory and status file from the resolved+    /// configuration, so a resident model with a mock library still has to know+    /// which root it is a model of.     init(         readyRepository: any LibraryProviding,+        configuration: LibraryConfiguration? = nil,         mirroringDeclarationFailure: String? = nil,         capabilities: AsterismCapabilities = .current     ) {         self.capabilities = capabilities-        self.explicitConfiguration = nil+        self.explicitConfiguration = configuration         self.appGroupIdentifier = nil         self.cloudKitContainerID = nil         self.mirroringDeclarationFailure = mirroringDeclarationFailure         self.locator = SystemSharedContainerLocator()         self.startupFailureMessage = nil         self.uiTestFixture = nil+        self.resolvedConfiguration = configuration         self.repository = readyRepository         self.state = .ready     }@@ -274,7 +281,23 @@ public final class AppLibraryModel {     /// 134422 collision (Q24). `retry()` and the UI-test reseed path are the two     /// ways here.     public func bootstrap() async {+        // Decision 1's protocol. Set synchronously, before this method's first+        // suspension, so a pass can never slip in between the flag and the+        // drain below: `runBackgroundExport()` reads it before it creates a+        // task, and `acquire()` reads it again.+        bootstrapInFlight = true+        defer { bootstrapInFlight = false }         state = .loading+        // The foreground *pre-empts* a pass rather than waiting for it: a reader+        // who opens the app should see the library, not a spinner for the length+        // of a grant (Req 1.7). A loop rather than one await, because the main+        // actor is reentrant at every suspension and a joiner can take the+        // handle across this one.+        while let pass = backgroundExportTask {+            pass.cancel()+            _ = await pass.value+        }+        assert(backgroundSession == nil, "a pre-empted pass releases its own session")         await teardownRepository()         if let startupFailureMessage {             state = .unavailable(message: startupFailureMessage)@@ -283,20 +306,10 @@ public final class AppLibraryModel {         }          do {-            let configuration: LibraryConfiguration-            if let explicit = explicitConfiguration {-                configuration = explicit-            } else {-                guard let appGroupIdentifier else {-                    state = .unavailable(message: "No App Group identifier configured.")-                    return-                }-                configuration = try LibraryConfiguration.production(-                    appGroupIdentifier: appGroupIdentifier,-                    cloudKitContainerID: cloudKitContainerID,-                    locator: locator-                )-            }+            // Resolved through the helper the pass's cold open shares, so the+            // two openers can never disagree about which store they are opening+            // (design, §Resident versus cold).+            let configuration = try resolveConfiguration()             resolvedConfiguration = configuration              // The app-role opener acquires the exclusive lease before its first@@ -314,7 +327,8 @@ public final class AppLibraryModel {             // only (`SiteRelationshipPopulationPass`).             var repo = try await LibraryRepository.openForApp(                 configuration,-                capabilities: capabilities+                capabilities: capabilities,+                mirroring: mirroringOpenHooks             ).repository              if let uiTestFixture {@@ -333,7 +347,8 @@ public final class AppLibraryModel {                     // `validate(graph:)` at open derives.                     repo = try await LibraryRepository.openForApp(                         configuration,-                        capabilities: capabilities+                        capabilities: capabilities,+                        mirroring: mirroringOpenHooks                     ).repository                 }             }@@ -360,6 +375,8 @@ public final class AppLibraryModel {             // Req 6.4).             startSyncObservation(                 configuration: configuration, mirroring: await repo.mirroring)+            // After the monitor, because it parks on it (Req 2.4).+            armExportOwedClearing()             scheduleLaunchReconcile()             // `character-extraction` Req 1.1: a cold launch *is* the app becoming             // active, but `didBecomeActive` fires while this is still opening —@@ -371,6 +388,10 @@ public final class AppLibraryModel {                 Task { await characterExtraction.activationSweep() }             }             Self.logger.debug("Library bootstrap completed")+        } catch let error as UnresolvableConfiguration {+            // The one failure that has always been reported as a sentence of+            // its own rather than as a description of an error.+            state = .unavailable(message: error.message)         } catch {             state = .unavailable(message: String(describing: error))             Self.logger.error("Library bootstrap failed: \(String(describing: error), privacy: .public)")@@ -402,6 +423,15 @@ public final class AppLibraryModel {     /// reason — a pass still running against the old repository has nothing to     /// say about the library the next open publishes.     private func teardownRepository() async {+        // Defensive (Decision 1). A pass releases its own session on every path,+        // so this can only find one if the model was torn down between an+        // `acquire()` and its `release()`; `release()` is idempotent, so a+        // teardown with no pass in sight is free.+        await release()+        // Req 2.4's arm is parked on the monitor stopped below and clears+        // through a configuration this teardown is releasing.+        exportOwedClearingTask?.cancel()+        exportOwedClearingTask = nil         stopSyncObservation()         launchReconcileTask?.cancel()         launchReconcileTask = nil@@ -472,12 +502,32 @@ public final class AppLibraryModel {     /// holding, then re-derive the diagnoses and republish the snapshots.     public func drainAndReconcile() async {         guard state == .ready, repository != nil else { return }+        // What a pass deferred while it held the library. The mirror imports as+        // well as exports during a grant, and nothing it brought in is+        // reconciled until the reader comes back — which is this. Only the+        // reconcile runs here: the refresh the arrival owes is the one below,+        // which this activation was going to do anyway.+        let deferredArrivals = arrivalsDeferredByPass+        if deferredArrivals {+            arrivalsDeferredByPass = false+            await reconcileArrivals()+        }         // Req 3.4's bar is this guard: a preserved capture is committed only into         // a ready library. The pass runs before the refresh below so that what it         // commits reaches the snapshots this activation publishes, and it takes         // the larger budget — nothing is waiting on it but the refresh (Q40).         await drainPendingCaptures(budget: PendingCaptureBounds.drainPassTimeBudget)         await refreshDiagnosesAndSnapshots()+        if deferredArrivals {+            // Read *after* a refresh, for the reason `handleSyncArrivals()`+            // gives: the refresh's scan is what spots the sets a gated pass did+            // not process (Q58). Which refresh does not matter, and this+            // activation has just done one.+            await scheduleDuplicateFollowUpIfNeeded()+        }+        // Re-armed on every activation, so a capture shared since the last one+        // is picked up (Req 2.4, Q23).+        armExportOwedClearing()     }      /// A record arrived in the queue while the app was running (Req 4.6,@@ -1223,6 +1273,297 @@ public final class AppLibraryModel {         }     } +    // MARK: - Background export (background-export, Decision 1)++    /// The sentence a pass is told when the foreground is already opening.+    ///+    /// Not a failure: that open's own monitor exports the same changes, and the+    /// process is alive to do it.+    static let openingLibraryReason = "the library is opening"++    /// How both openers construct the app's mirrored container.+    ///+    /// `.production` everywhere but a test. `.private` construction always fails+    /// on a host with no iCloud entitlement, so the cold arm of a pass is+    /// unreachable without a stand-in — and `bootstrap()` takes the same value,+    /// which is what lets a test count the containers *both* openers make and+    /// state Req 1.8 as a number.+    var mirroringOpenHooks: MirroringOpenHooks = .production++    /// Whether `bootstrap()` is between its first statement and its return+    /// (Decision 1).+    private(set) var bootstrapInFlight = false++    /// Whether a pass currently holds the resident library. Gates+    /// `handleSyncArrivals()`.+    private(set) var backgroundPassHoldsLibrary = false++    /// An arrival that landed while a pass held the library, owed to the next+    /// `drainAndReconcile()`. Nothing outside this model reads it: what the+    /// tests assert is the reconcile it produces, not the flag.+    private var arrivalsDeferredByPass = false++    /// The configuration a pass in flight resolved, read by the cold arm of+    /// `acquire()`.+    ///+    /// Deliberately not `resolvedConfiguration`, which means "the foreground has+    /// a live library" and is nil on exactly the launch a cold pass runs on.+    /// Set before the task is created and cleared as the task ends, so it is+    /// live for precisely the pass that resolved it.+    private var passConfiguration: LibraryConfiguration?++    /// What a cold pass opened: deliberately neither `repository` nor `state`,+    /// so a pass on a scene-less launch publishes nothing the foreground reads+    /// and leaves nothing behind when it releases (Req 1.5).+    private(set) var backgroundSession: (repository: LibraryRepository, monitor: SyncMonitor)?++    /// The pass in flight. Concurrent callers join it rather than starting a+    /// second one, and `bootstrap()` has something to cancel (Q30).+    private(set) var backgroundExportTask: Task<BackgroundExportOutcome, Never>?++    /// Test seam: whether the *foreground* holds a repository. `repository`+    /// itself stays private, because Req 1.5 turns on a pass never publishing+    /// into it.+    var hasOpenForegroundRepository: Bool { repository != nil }++    /// The one configuration failure reported with a sentence of its own.+    private struct UnresolvableConfiguration: Error {+        let message: String+    }++    /// The configuration this model is a model of, resolved the same way for+    /// both openers (design, §Resident versus cold).+    private func resolveConfiguration() throws -> LibraryConfiguration {+        if let explicit = explicitConfiguration { return explicit }+        guard let appGroupIdentifier else {+            throw UnresolvableConfiguration(message: "No App Group identifier configured.")+        }+        return try LibraryConfiguration.production(+            appGroupIdentifier: appGroupIdentifier,+            cloudKitContainerID: cloudKitContainerID,+            locator: locator)+    }++    /// `resolveConfiguration()` with every throw already turned into the+    /// sentence a skipped pass reports (Req 1.9). `bootstrap()` keeps the+    /// throwing spelling: it has a `state` to put the diagnosis in.+    private func configurationForPass() -> Result<LibraryConfiguration, UnresolvableConfiguration> {+        do {+            return .success(try resolveConfiguration())+        } catch let error as UnresolvableConfiguration {+            return .failure(error)+        } catch {+            return .failure(UnresolvableConfiguration(message: String(describing: error)))+        }+    }++    /// Runs one background export pass against this model's library+    /// (Decision 1): settle the markers, take the library, wait for the mirror,+    /// clear what was exported, release.+    ///+    /// Platform-neutral. On iOS the refresh handler and the Development trigger+    /// both call this; on the Mac nothing does (Q16).+    ///+    /// A caller with a pass already in flight **joins** it rather than starting+    /// a second one, and every caller awaits through a cancellation handler —+    /// awaiting a stored `Task`'s value does not on its own propagate the+    /// awaiting task's cancellation, so without this SwiftUI's expiry would+    /// never have reached the wait (Q30). Any joiner's cancellation therefore+    /// expires the shared pass.+    public func runBackgroundExport() async -> BackgroundExportOutcome {+        if let running = backgroundExportTask { return await joining(running) }+        // Read synchronously, before the task exists: `bootstrap()` sets the+        // flag before its own first suspension, so there is no window between+        // this and its drain loop.+        guard !bootstrapInFlight else {+            // No pass exists on this arm, so nothing else would log it. A grant+            // that leaves no pair of lines in Console is a grant the runbook+            // cannot account for (Req 4.1, Q32).+            let outcome = BackgroundExportOutcome.skipped(+                .unavailable(Self.openingLibraryReason))+            BackgroundExportPass.reportUnstarted(outcome)+            return outcome+        }+        let configuration: LibraryConfiguration+        switch configurationForPass() {+        case .success(let resolved):+            configuration = resolved+        case .failure(let error):+            let outcome = BackgroundExportOutcome.skipped(.unavailable(error.message))+            BackgroundExportPass.reportUnstarted(outcome)+            return outcome+        }+        let pass = BackgroundExportPass(+            marker: ExportOwedMarker(directory: configuration.exportOwedURL),+            statusURL: configuration.syncStatusURL)+        // Handed to the cold arm rather than resolved a second time inside it.+        passConfiguration = configuration+        // `self` strongly: the caller below awaits this task while holding the+        // model, so a weak capture could never find it gone.+        let task = Task { @MainActor in+            // Cleared as the task's last act, so a later call starts a fresh+            // pass rather than joining a finished one — and so `bootstrap()`'s+            // drain loop terminates.+            defer {+                self.backgroundExportTask = nil+                self.passConfiguration = nil+            }+            return await pass.run(session: self)+        }+        backgroundExportTask = task+        return await joining(task)+    }++    /// Awaits a shared pass so that *this* caller's cancellation reaches it.+    private func joining(+        _ pass: Task<BackgroundExportOutcome, Never>+    ) async -> BackgroundExportOutcome {+        await withTaskCancellationHandler {+            await pass.value+        } onCancel: {+            pass.cancel()+        }+    }++    /// The library session the pass runs against (design, §Resident versus+    /// cold). One row per situation, in the order that makes them exclusive.+    public func acquire() async -> BackgroundExportAcquisition {+        // First, because it has to dominate: that open's own monitor exports the+        // same changes, and the process is alive to do it.+        if bootstrapInFlight { return .skipped(.unavailable(Self.openingLibraryReason)) }+        // The foreground has already diagnosed it; a pass adds nothing (Req 1.9).+        if case .unavailable(let message) = state { return .skipped(.unavailable(message)) }+        if state == .ready, let repository {+            // Req 1.10: an import or a reconciliation is already saving, and is+            // therefore already exporting. Neither started nor interrupted.+            if await repository.isBulkOperationInProgress() { return .skipped(.bulkOperation) }+            guard let monitor = syncMonitor else {+                return .skipped(.unavailable("mirroring is not attached"))+            }+            backgroundPassHoldsLibrary = true+            return .monitor(monitor)+        }+        return await acquireColdSession()+    }++    /// The cold arm: a background launch with no scene and nothing open+    /// (Req 3.5).+    private func acquireColdSession() async -> BackgroundExportAcquisition {+        // The configuration `runBackgroundExport()` resolved for the pass that+        // is calling this. It cannot be absent — a pass exists only after it was+        // set — and re-resolving it here would be a second chance for the two+        // openers to disagree about which store they are opening.+        guard let configuration = passConfiguration else {+            return .skipped(.unavailable("the pass has no resolved configuration"))+        }++        // Q26: **before** the open. The monitor observes notifications only and+        // the certification container emits none, so starting early is free —+        // where starting after the open could miss a setup or export event that+        // completed while it ran. `onArrivals` stays nil: a pass reconciles+        // nothing.+        let monitor = SyncMonitor(+            storeURL: configuration.storeURL,+            statusURL: configuration.syncStatusURL)+        monitor.start()++        let opened: LibraryRepository+        do {+            opened = try await LibraryRepository.openForApp(+                configuration,+                capabilities: capabilities,+                mirroring: mirroringOpenHooks+            ).repository+        } catch is CancellationError {+            // The open is cancellable only while it waits for the cross-process+            // lock, which is exactly the wait an expiring grant must not sit+            // through. Reported as expired or pre-empted, never as unavailable.+            monitor.stop()+            return .cancelled+        } catch {+            // Req 1.9: a store below this build's floor, an unverifiable store, a+            // lock timeout, a store protected before first unlock. Nothing was+            // written, the marker survives, and the foreground diagnoses it at+            // the next open exactly as it does today.+            monitor.stop()+            return .skipped(.unavailable(String(describing: error)))+        }++        guard await opened.mirroring.isMirroring else {+            // The library opened, but there is no mirror to wait on — this+            // configuration names no container, or the construction failed and+            // fell back to local-only (Q44). Nothing to export through.+            monitor.stop()+            await opened.shutdown()+            return .skipped(.unavailable("the library opened without CloudKit mirroring attached"))+        }+        backgroundSession = (repository: opened, monitor: monitor)+        return .monitor(monitor)+    }++    /// Releases whatever `acquire()` took. Idempotent, and called on every path+    /// that acquired — including cancellation, and before the outcome is+    /// reported (Req 1.5).+    public func release() async {+        backgroundPassHoldsLibrary = false+        guard let session = backgroundSession else { return }+        backgroundSession = nil+        session.monitor.stop()+        await session.repository.shutdown()+    }++    /// Whether the cancellation a pass is seeing came from the reader opening+    /// the app rather than from the system's expiry (Q30).+    public var isPreempting: Bool { bootstrapInFlight }++    /// The parked foreground clearing, or nil when nothing is owed.+    private var exportOwedClearingTask: Task<Void, Never>?++    /// Clears the markers the foreground's own mirror has exported (Req 2.4).+    ///+    /// The same two halves of the settlement rule the pass applies. The+    /// **persisted** half settles, at listing time and with no wait, every+    /// marker older than the latest successful export start `SyncMonitor` has+    /// recorded. The **live** half parks on this session's monitor for an+    /// export that starts later than the newest of what is left, and clears+    /// exactly those names when one arrives. Without it every grant after+    /// ordinary use would open the library for nothing, since the foreground+    /// mirror has already exported.+    ///+    /// Armed at the end of `bootstrap()` and again at every activation, not on+    /// every remote change: a marker written while the app is resident and+    /// never activated again is settled by the next grant's persisted check, and+    /// a per-notification re-arm would list the directory on every hydration+    /// transaction for no benefit (Q23). An earlier arm is replaced — cancelled+    /// — by the next, and `teardownRepository()` cancels the last one, because+    /// it is parked on a monitor that is about to stop.+    ///+    /// Compiled and run on **every** platform. On the Mac this is the whole of+    /// the feature: there is no `BGTaskScheduler` there, the process stays+    /// resident, and its mirror exports whenever the app runs (Q16).+    func armExportOwedClearing() {+        exportOwedClearingTask?.cancel()+        exportOwedClearingTask = nil+        guard let configuration = resolvedConfiguration, let monitor = syncMonitor else { return }+        let marker = ExportOwedMarker(directory: configuration.exportOwedURL)+        // A directory that cannot be listed is left alone. The foreground has+        // no grant to account for, and the next pass reports the condition+        // properly (Req 2.6).+        guard let listed = try? marker.pending(), !listed.isEmpty else { return }+        let outstanding = BackgroundExportPass.settle(+            listed, against: monitor.status, marker: marker)+        guard !outstanding.isEmpty else { return }+        exportOwedClearingTask = Task { @MainActor in+            // The same live half the pass runs, threshold and clearing included.+            // No deadline: there is no grant to spend here, so the wait ends+            // when an export qualifies, when the monitor stops under it, or+            // when the next arm or the teardown cancels this task. The result+            // is the pass's to interpret; here there is nothing to report.+            _ = await BackgroundExportPass.awaitExportAndClear(+                outstanding, on: monitor, marker: marker, deadline: nil)+        }+    }+     // MARK: - Sync visibility (Req 8)      /// The live monitor, or nil when this configuration cannot mirror.@@ -1329,6 +1670,34 @@ public final class AppLibraryModel {     /// import re-fires it when its flag drops (Q46). Nothing here may add a     /// second gate.     func handleSyncArrivals() async {+        // A pass has the library: record the arrival and return. Reconciling+        // here would spend a background grant on the reader's screen, which is+        // the reconciliation non-goal made concrete; `drainAndReconcile()` runs+        // this again at the next activation.+        if backgroundPassHoldsLibrary {+            arrivalsDeferredByPass = true+            return+        }+        // This arrival is being reconciled now, so nothing is owed to the next+        // activation. Without this, one arrival deferred by a pass would leave+        // the flag set through every ordinary arrival after the pass released,+        // and the next activation would reconcile a second time for nothing.+        arrivalsDeferredByPass = false+        await reconcileArrivals()+        await refreshDiagnosesAndSnapshots()+        // Read *after* the refresh: its scan is what spots the sets a gated pass+        // did not process, which is how a hydration's final batch gets its pass+        // (Q58).+        await scheduleDuplicateFollowUpIfNeeded()+    }++    /// The reconcile an arrival owes, without the refresh that follows it.+    ///+    /// Split out because the deferred arrival and the activation that runs it+    /// share one refresh: `drainAndReconcile()` calls this and then refreshes+    /// once for both halves, where calling `handleSyncArrivals()` there ran the+    /// whole diagnosis-and-snapshot re-derivation twice per activation.+    private func reconcileArrivals() async {         guard let repo = repository else { return }         do {             // The arrival tier: the duplicate phase runs only where the last@@ -1337,15 +1706,11 @@ public final class AppLibraryModel {         } catch {             // Reconciliation has no user-facing errors: a failed pass stopped at             // a chunk boundary and the next trigger converges it. The refresh-            // below is not optional either way — records did arrive.+            // the caller does next is not optional either way — records did+            // arrive.             Self.logger.error(                 "Reconciliation after arrivals failed: \(String(describing: error), privacy: .public)")         }-        await refreshDiagnosesAndSnapshots()-        // Read *after* the refresh: its scan is what spots the sets a gated pass-        // did not process, which is how a hydration's final batch gets its pass-        // (Q58).-        await scheduleDuplicateFollowUpIfNeeded()     }      /// Schedules the once-per-launch reconcile (Q45).@@ -1908,3 +2273,9 @@ public final class AppLibraryModel {         Self.logger.debug("Seeded isolated UI test fixture")     } }++/// Decision 1: the pass asks the model for a library, and the model is the only+/// thing that knows whether one is live. Declared here rather than carrying the+/// members, so `acquire()` and `release()` stay in the class body where the+/// private state they read lives.+extension AppLibraryModel: BackgroundExportSession {}
Asterism/Asterism/ViewModels/BackgroundExportTriggerModel.swift Added +66 / -0
diff --git a/Asterism/Asterism/ViewModels/BackgroundExportTriggerModel.swift b/Asterism/Asterism/ViewModels/BackgroundExportTriggerModel.swiftnew file mode 100644index 0000000..93a9b6a--- /dev/null+++ b/Asterism/Asterism/ViewModels/BackgroundExportTriggerModel.swift@@ -0,0 +1,66 @@+#if DEBUG+import AsterismCore+import Foundation++/// The `Development`-only control that runs one background export pass on+/// demand (Req 4.2, Q6).+///+/// Grants cannot be exercised on a simulator, and Xcode's simulated launch needs+/// a debugger attached to a running process — so without this the pass could not+/// be checked on a device at all without waiting for iOS to decide to hand one+/// over.+///+/// It calls `AppLibraryModel.runBackgroundExport()`, which is the **same**+/// method the refresh handler calls, so what the button exercises is the pass+/// rather than a copy of it.+///+/// The whole file is behind `#if DEBUG`, which is Q17's gate and the whole of+/// Req 4.2: `DEBUG` is set on the `Development` configuration only, so a+/// `Personal` build has neither this type nor the row that shows it.+///+/// **Not a platform fork**, which the design's prose asked for.+/// `ipad-and-mac-layouts` Req 4.5 keeps every `#if os(` in four named files and+/// `PlatformSeamTests` enforces it, so one here would be a second view layer+/// starting. Nothing is lost: `runBackgroundExport()` is platform-neutral, and a+/// `Development` Mac gets a button that runs the same pass against its resident+/// library. What the Mac has no share of is the *scheduler* (Q16), which lives+/// elsewhere and stays iOS-only.+@MainActor+@Observable+final class BackgroundExportTriggerModel {+    enum State: Equatable {+        case idle+        case running+        /// The outcome, worded. Every sentence this surface shows comes from+        /// here rather than from the view, per `SettingsView`'s convention.+        case finished(String)+    }++    private(set) var state: State = .idle++    /// One pass. Injected rather than reached through the model, so this type+    /// knows nothing about how a library is opened.+    private let pass: () async -> BackgroundExportOutcome++    init(pass: @escaping () async -> BackgroundExportOutcome) {+        self.pass = pass+    }++    /// Runs one pass and reports what it did.+    ///+    /// A second tap while one is in flight does nothing: `runBackgroundExport()`+    /// would join the pass already running, and two rows reporting one pass is+    /// two claims about it.+    func run() async {+        guard state != .running else { return }+        state = .running+        state = .finished(Self.sentence(for: await pass()))+    }++    /// The pass's own vocabulary (Req 4.1) — the same words the Console record+    /// carries, so a developer reading one recognises the other.+    static func sentence(for outcome: BackgroundExportOutcome) -> String {+        "Last pass: \(outcome.logDescription)"+    }+}+#endif
Asterism/Asterism/Views/SettingsView.swift Modified +73 / -1
diff --git a/Asterism/Asterism/Views/SettingsView.swift b/Asterism/Asterism/Views/SettingsView.swiftindex d110fee..71cbfe7 100644--- a/Asterism/Asterism/Views/SettingsView.swift+++ b/Asterism/Asterism/Views/SettingsView.swift@@ -75,6 +75,23 @@ struct SettingsView: View {     /// like every other sync fact this screen shows.     private let isAwaitingFirstSync: Bool +    /// Req 4.2's Development-only trigger, injected as the pass itself rather+    /// than as its model.+    ///+    /// A closure because `BackgroundExportTriggerModel` lives behind `#if DEBUG`+    /// and an initializer parameter cannot itself be `#if`-gated;+    /// `BackgroundExportOutcome` is unconditional, so this signature compiles in+    /// every configuration and the *row* is what the gate withholds.+    private let runBackgroundExport: (() async -> BackgroundExportOutcome)?++    #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+    /// presenting view, and a model rebuilt with them would forget the outcome+    /// it was showing.+    @State private var backgroundExportTrigger: BackgroundExportTriggerModel?+    #endif+     init(         model: SettingsBackupModel,         importModel: SettingsBackupImportModel? = nil,@@ -90,7 +107,8 @@ struct SettingsView: View {         drainReportNotice: DrainReportNotice? = nil,         onDeleteSetAsideCapture: ((UUID) -> Void)? = nil,         onDismissDrainReport: (() -> Void)? = nil,-        onOpenDrainedEntry: ((UUID) -> Void)? = nil+        onOpenDrainedEntry: ((UUID) -> Void)? = nil,+        runBackgroundExport: (() async -> BackgroundExportOutcome)? = nil     ) {         _model = State(initialValue: model)         _importModel = State(initialValue: importModel)@@ -107,6 +125,11 @@ struct SettingsView: View {         self.onDeleteSetAsideCapture = onDeleteSetAsideCapture         self.onDismissDrainReport = onDismissDrainReport         self.onOpenDrainedEntry = onOpenDrainedEntry+        self.runBackgroundExport = runBackgroundExport+        #if DEBUG+        _backgroundExportTrigger = State(+            initialValue: runBackgroundExport.map { BackgroundExportTriggerModel(pass: $0) })+        #endif     }      var body: some View {@@ -229,6 +252,7 @@ struct SettingsView: View {                 DisclosureGroup(isExpanded: $showingDebug) {                     syncRows                     diagnosticsRow+                    backgroundExportRow                 } label: {                     Text("Debug")                         .font(.subheadline)@@ -532,6 +556,54 @@ struct SettingsView: View {         }     } +    // MARK: - Background export trigger (Req 4.2)++    /// One row, following `backupRow`'s state switch: the button, the pass+    /// running, then what it did. Empty in `Personal`, which defines no `DEBUG`+    /// and is therefore handed no trigger to show (Req 4.2, Q17).+    @ViewBuilder+    private var backgroundExportRow: some View {+        #if DEBUG+        if let backgroundExportTrigger {+            switch backgroundExportTrigger.state {+            case .idle:+                Button {+                    Task { await backgroundExportTrigger.run() }+                } label: {+                    Label("Run background export", systemImage: "arrow.up.circle")+                }+                .accessibilityIdentifier("settings-background-export-run")++            case .running:+                HStack {+                    ProgressView()+                        .accessibilityIdentifier("settings-background-export-progress")+                    Text("Running a pass…")+                        .foregroundStyle(.secondary)+                }++            case .finished(let sentence):+                VStack(alignment: .leading, spacing: 8) {+                    // The identifier goes on the `Text` rather than on this+                    // stack: a container's identifier is inherited by every+                    // descendant and would take the button's name with it+                    // (`docs/agent-notes/testing.md`).+                    Text(sentence)+                        .font(.callout)+                        .accessibilityIdentifier("settings-background-export-result")+                    Button {+                        Task { await backgroundExportTrigger.run() }+                    } label: {+                        Text("Run background export")+                    }+                    .buttonStyle(.borderless)+                    .accessibilityIdentifier("settings-background-export-run")+                }+            }+        }+        #endif+    }+     // MARK: - Backup Row      @ViewBuilder
Asterism/AsterismShareExtension/ShareCaptureSession.swift Modified +49 / -3
diff --git a/Asterism/AsterismShareExtension/ShareCaptureSession.swift b/Asterism/AsterismShareExtension/ShareCaptureSession.swiftindex 05ab1c2..954624f 100644--- a/Asterism/AsterismShareExtension/ShareCaptureSession.swift+++ b/Asterism/AsterismShareExtension/ShareCaptureSession.swift@@ -1,5 +1,6 @@ import AsterismCore import ConstellationKit+import OSLog import SwiftUI  /// One activation of the share extension, independent of the platform hosting@@ -60,6 +61,20 @@ final class ShareCaptureSession {     /// declined.     private var liveSheet: (preservedID: UUID, spool: PendingCaptureSpool)? +    /// Where a committed capture leaves its export-owed marker+    /// (background-export Req 2.1). Built in `bootstrap()` step 1 beside the+    /// spool, from the *same* resolved configuration: the marker directory and+    /// the pending-capture queue are two halves of one contract with the app,+    /// and resolving the App Group twice is how halves drift apart.+    ///+    /// Nil until step 1 has run, and on the arm where the configuration could+    /// not be resolved at all — there is nowhere to mark, and nothing was+    /// captured to mark for.+    private var exportOwedMarker: ExportOwedMarker?++    private static let logger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "BackgroundExport")+     init(extensionContext: NSExtensionContext?) {         self.extensionContext = extensionContext     }@@ -96,8 +111,9 @@ final class ShareCaptureSession {     // MARK: - Bootstrap      private func bootstrap() async {-        // Resolved by step 1 and needed by step 4; the flow owns the order, so-        // it is carried between the two closures rather than resolved twice.+        // Resolved by step 1, needed by step 4 and by the marker below; the flow+        // owns the order, so it is carried between the two closures rather than+        // resolved twice.         var resolved: LibraryConfiguration?          let flow = ShareCaptureFlow<LibraryRepository>(@@ -150,7 +166,16 @@ final class ShareCaptureSession {                 ).repository             }) -        await apply(flow.run())+        let decision = await flow.run()+        // Built here rather than inside `resolve`, which stays a pure resolver:+        // step 1 is the only thing that knows the configuration, and this is the+        // first place after it that can hold what it resolved. Nil when the+        // resolution failed, which is the arm that preserves nothing and marks+        // nothing.+        if let configuration = resolved {+            exportOwedMarker = ExportOwedMarker(directory: configuration.exportOwedURL)+        }+        await apply(decision)     }      /// Shows what the flow decided and then does to the request what that@@ -202,6 +227,27 @@ final class ShareCaptureSession {             if outcome.discardsPreserved {                 await spool.discardPreserved(id: preservedID)             }+            if outcome == .committed {+                // background-export Req 2.1. **Position is the precondition, not+                // a preference.** The settlement rule reads a marker as "there+                // is a commit at least this old", and it clears the marker on an+                // export that *started* after it — so the marker must be written+                // after the commit it stands for is durable, and must stay after+                // it (design §The marker). Moving this earlier would let an+                // export that began before the commit settle a marker for it,+                // and the capture would wait for the next export instead.+                //+                // A failure is logged and ignored: Req 2.5 says the cost of a+                // lost marker is the latency this feature removes, never a lost+                // capture, so nothing here may reach `completeExtension()`.+                do {+                    try self.exportOwedMarker?.mark()+                } catch {+                    Self.logger.error(+                        "Could not write the export-owed marker: \(error.localizedDescription, privacy: .public)"+                    )+                }+            }             switch outcome.request {             case .complete: self.completeExtension()             case .cancel: self.cancelExtension()
Asterism/AsterismTests/AppLibraryModelBackgroundExportTests.swift Added +551 / -0
diff --git a/Asterism/AsterismTests/AppLibraryModelBackgroundExportTests.swift b/Asterism/AsterismTests/AppLibraryModelBackgroundExportTests.swiftnew file mode 100644index 0000000..f4dd0d5--- /dev/null+++ b/Asterism/AsterismTests/AppLibraryModelBackgroundExportTests.swift@@ -0,0 +1,551 @@+import AsterismCore+import Foundation+import SwiftData+import Testing+@testable import Asterism++/// Task 7: the library session `AppLibraryModel` gives the background export+/// pass — reuse, cold open, pre-emption, and the arrivals gate+/// (Reqs 1.5–1.11, 3.5; Decision 1).+///+/// Held apart from `AppLibraryModelTests` because it needs a fixture family of+/// its own — a counting mirrored-container factory, a gate that holds an open+/// mid-flight — and because that suite is already 1,400 lines. Everything under+/// test is still `AppLibraryModel`'s.+///+/// The app's test bundle imports `AsterismCore` **without** `@testable`, so+/// every Core seam used here is public: `LibraryConfiguration`,+/// `MirroringOpenHooks(makeMirroredContainer:)`, `LibraryRepository.openForApp`+/// and `openContainer`, `SyncMonitor.observe`/`isObserving`, and the pass's own+/// outcome type.+@Suite("AppLibraryModel background export", .serialized)+@MainActor+struct AppLibraryModelBackgroundExportTests {++    // MARK: - Fixtures++    /// A fictional container id. The real values are declared once in the Xcode+    /// project and the identity lint sweeps for them, so nothing here may name+    /// one (`configuration-identity` Req 2.2).+    private static let fixtureContainerID = "iCloud.example.fixture"++    private func tempRoot() -> URL {+        FileManager.default.temporaryDirectory+            .appending(path: "asterism-background-export-\(UUID())")+    }++    private func configuration(mirroring: Bool, at root: URL) -> LibraryConfiguration {+        LibraryConfiguration(+            rootDirectory: root,+            cloudKitContainerID: mirroring ? Self.fixtureContainerID : nil)+    }++    /// What the mirrored-container factory did, and how many of the containers+    /// it made were alive at once.+    ///+    /// Req 1.8 is a statement about concurrency — never two mirrored containers+    /// over one store — and the only way to observe it from outside the package+    /// is to hold every container this factory made **weakly** and count the+    /// survivors at each construction. Locked because the factory runs on+    /// whatever executor `openForApp` is running on, not on the main actor+    /// (`docs/agent-notes/testing.md`, "Adding recorded state … needs a lock").+    private final class ContainerFactoryLog: @unchecked Sendable {+        private final class WeakContainer {+            weak var value: ModelContainer?+            init(_ value: ModelContainer) { self.value = value }+        }++        private let lock = NSLock()+        private var made: [WeakContainer] = []+        private var mark = 0++        var constructionCount: Int { lock.withLock { made.count } }+        var concurrentHighWaterMark: Int { lock.withLock { mark } }+        var allReleased: Bool { lock.withLock { made.allSatisfy { $0.value == nil } } }++        func record(_ container: ModelContainer) {+            lock.withLock {+                made.append(WeakContainer(container))+                mark = max(mark, made.filter { $0.value != nil }.count)+            }+        }+    }++    /// Holds an open inside the container factory until the test lets it go, so+    /// a second opener can be observed arriving while the first still has the+    /// library.+    ///+    /// The wait is bounded: a gate nobody opens must fail its test rather than+    /// hang the suite.+    private final class Gate: @unchecked Sendable {+        private let semaphore = DispatchSemaphore(value: 0)+        func hold() { _ = semaphore.wait(timeout: .now() + 5) }+        func release() { semaphore.signal() }+    }++    /// A factory that stands in for the real `.private` construction, which+    /// always fails on a host with no iCloud entitlement.+    private func countingHooks(+        _ log: ContainerFactoryLog, gate: Gate? = nil+    ) -> MirroringOpenHooks {+        MirroringOpenHooks(makeMirroredContainer: { storeURL, _ in+            gate?.hold()+            let container = try LibraryRepository.openContainer(at: storeURL)+            log.record(container)+            return container+        })+    }++    /// Leaves one marker, exactly as the extension would.+    @discardableResult+    private func mark(_ configuration: LibraryConfiguration) throws -> String {+        let marker = ExportOwedMarker(directory: configuration.exportOwedURL)+        let before = Set(try marker.pending().map(\.name))+        try marker.mark()+        return try #require(try marker.pending().map(\.name).first { !before.contains($0) })+    }++    private func pendingMarkers(_ configuration: LibraryConfiguration) throws -> [String] {+        try ExportOwedMarker(directory: configuration.exportOwedURL).pending().map(\.name)+    }++    /// Bounded polling. A condition that never holds fails the test rather than+    /// hanging the suite, and the sleep is what hands the main actor to the+    /// pass under test.+    private func waitUntil(+        _ what: String, timeout: TimeInterval = 15, _ condition: () -> Bool,+        sourceLocation: SourceLocation = #_sourceLocation+    ) async {+        let deadline = Date().addingTimeInterval(timeout)+        while Date() < deadline {+            if condition() { return }+            try? await Task.sleep(for: .milliseconds(2))+        }+        Issue.record("Timed out waiting for \(what)", sourceLocation: sourceLocation)+    }++    /// The monitor the cold path built, caught while the pass still holds it:+    /// after the release there is nothing left on the model to read it from.+    private func coldMonitor(of model: AppLibraryModel) async -> SyncMonitor? {+        await waitUntil("the pass to open a session of its own") {+            model.backgroundSession != nil+        }+        return model.backgroundSession?.monitor+    }++    // MARK: - The cold path (Reqs 1.5, 3.5)++    @Test("A cold pass opens a session of its own and releases everything it took")+    func coldPassOpensAndReleases() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        try mark(configuration)++        let log = ContainerFactoryLog()+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(log)++        let pass = Task { await model.runBackgroundExport() }+        let monitor = try #require(await coldMonitor(of: model))+        // Nothing exports on a host, so the grant expiring is what ends the+        // wait. Every release assertion below is on that path (Req 1.4).+        pass.cancel()+        let outcome = await pass.value++        #expect(outcome == .expired)+        #expect(!model.hasOpenForegroundRepository, "the pass never publishes its repository")+        #expect(model.state == .loading, "and never moves the foreground's state")+        #expect(model.backgroundSession == nil, "the session is released before the outcome")+        #expect(log.constructionCount == 1)+        #expect(!monitor.isObserving, "the pass stops the monitor it started")+        #expect(monitor.onArrivals == nil, "nothing is reconciled during a pass")+        // Req 1.5: no store file in the App Group container is left open.+        await waitUntil("the mirrored container to be released") { log.allReleased }+    }++    @Test("A root that cannot mirror is reported unavailable, after the open it made is shut down")+    func coldPassOnANonMirroringRootSkips() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: false, at: root)+        let name = try mark(configuration)++        let log = ContainerFactoryLog()+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(log)++        let outcome = await model.runBackgroundExport()++        guard case .skipped(.unavailable(let reason)) = outcome else {+            Issue.record("A library that cannot mirror is unavailable to a pass, got \(outcome)")+            return+        }+        #expect(reason.localizedCaseInsensitiveContains("mirroring"))+        #expect(log.constructionCount == 0, "no container identifier, no mirrored construction")+        #expect(model.backgroundSession == nil)+        #expect(try pendingMarkers(configuration) == [name], "the marker survives a skip")+    }++    /// Req 1.9: a store this build refuses ends the pass without modifying it.+    @Test("A refused store skips as unavailable, with the store and the marker untouched")+    func refusedStoreSkipsWithoutTouchingTheStore() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)++        // A real library first, so there is a store to leave alone.+        let seedLog = ContainerFactoryLog()+        let seeded = try await LibraryRepository.openForApp(+            configuration, mirroring: countingHooks(seedLog)).repository+        await seeded.shutdown()+        // A generation this build does not open: refused before any container+        // is constructed, which is what makes "no modification" structural.+        try "3".write(to: configuration.readinessMarkerURL, atomically: true, encoding: .utf8)+        let storeBefore = try Data(contentsOf: configuration.storeURL)+        let name = try mark(configuration)++        let log = ContainerFactoryLog()+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(log)++        let outcome = await model.runBackgroundExport()++        guard case .skipped(.unavailable) = outcome else {+            Issue.record("A refused store is unavailable to a pass, got \(outcome)")+            return+        }+        #expect(log.constructionCount == 0, "a refused store never reaches a container")+        #expect(try Data(contentsOf: configuration.storeURL) == storeBefore)+        #expect(try pendingMarkers(configuration) == [name], "the marker survives (Req 1.9)")+        #expect(model.state == .loading, "the foreground diagnoses it at the next open, not here")+    }++    /// Req 1.11: a pass writes nothing beyond what every foreground open does.+    @Test("A cold pass leaves the record counts exactly as it found them")+    func coldPassLeavesTheRecordCountsAlone() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)++        let before = try await LibraryRepository.openForApp(+            configuration, mirroring: countingHooks(ContainerFactoryLog())).repository+        let countsBefore = try await before.recordCounts()+        await before.shutdown()+        try mark(configuration)++        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(ContainerFactoryLog())+        let pass = Task { await model.runBackgroundExport() }+        _ = try #require(await coldMonitor(of: model))+        pass.cancel()+        _ = await pass.value++        let after = try await LibraryRepository.openForApp(+            configuration, mirroring: countingHooks(ContainerFactoryLog())).repository+        #expect(try await after.recordCounts() == countsBefore)+        await after.shutdown()+    }++    // MARK: - The resident path (Reqs 1.6, 1.10)++    @Test("A resident pass reuses the live library and constructs nothing")+    func residentPassConstructsNothing() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)++        let log = ContainerFactoryLog()+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(log)+        await model.bootstrap()+        #expect(model.state == .ready)+        let opened = log.constructionCount+        #expect(opened == 1, "the foreground open constructed the one mirrored container")+        try mark(configuration)++        let pass = Task { await model.runBackgroundExport() }+        await waitUntil("the pass to take the live library") { model.backgroundPassHoldsLibrary }+        pass.cancel()+        let outcome = await pass.value++        #expect(outcome == .expired)+        #expect(log.constructionCount == opened, "a resident library is reused, never reopened")+        #expect(model.backgroundSession == nil, "the resident path opens no session of its own")+        #expect(!model.backgroundPassHoldsLibrary, "and lifts its gate on release")+        #expect(model.state == .ready, "the reader's library is untouched (Req 1.7)")+    }++    @Test("A bulk operation in progress ends the pass without taking the library (Req 1.10)")+    func aBulkOperationSkipsThePass() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        let name = try mark(configuration)++        let mock = MockLibraryProvider()+        mock.bulkOperationInProgress = true+        let model = AppLibraryModel(readyRepository: mock, configuration: configuration)+        model.startSyncObservation(+            configuration: configuration,+            mirroring: .attached(containerID: Self.fixtureContainerID))++        let outcome = await model.runBackgroundExport()++        #expect(outcome == .skipped(.bulkOperation))+        #expect(!model.backgroundPassHoldsLibrary)+        #expect(try pendingMarkers(configuration) == [name])+    }++    @Test("Arrivals during a resident pass are deferred, and reconciled at the next activation")+    func arrivalsDuringAPassAreDeferred() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        try mark(configuration)++        let mock = MockLibraryProvider()+        let model = AppLibraryModel(readyRepository: mock, configuration: configuration)+        model.startSyncObservation(+            configuration: configuration,+            mirroring: .attached(containerID: Self.fixtureContainerID))++        let pass = Task { await model.runBackgroundExport() }+        await waitUntil("the pass to take the live library") { model.backgroundPassHoldsLibrary }+        await model.handleSyncArrivals()+        #expect(+            mock.reconcileAfterSyncCallCount == 0,+            "the reconciliation non-goal, made concrete: nothing is reconciled during a grant")++        pass.cancel()+        _ = await pass.value+        await model.drainAndReconcile()++        #expect(mock.reconcileAfterSyncCallCount == 1, "the deferred arrival runs at the activation")+    }++    // MARK: - Exclusion between the two openers (Req 1.8, Decision 1)++    @Test("A foreground bootstrap pre-empts a cold pass and reaches ready")+    func bootstrapPreemptsAColdPass() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        try mark(configuration)++        let log = ContainerFactoryLog()+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(log)++        let pass = Task { await model.runBackgroundExport() }+        _ = try #require(await coldMonitor(of: model))+        await model.bootstrap()+        let outcome = await pass.value++        #expect(outcome == .preempted, "distinguishable in the log from a system expiry (Q30)")+        #expect(model.state == .ready, "the reader sees the library, not a spinner (Req 1.7)")+        #expect(+            log.concurrentHighWaterMark == 1,+            "Req 1.8: never two mirrored containers over one store")+        #expect(model.backgroundSession == nil)+    }++    @Test("A pass arriving during a bootstrap skips as opening")+    func aPassDuringABootstrapSkipsAsOpening() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)++        let log = ContainerFactoryLog()+        let gate = Gate()+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(log, gate: gate)++        let boot = Task { await model.bootstrap() }+        await waitUntil("the bootstrap to be in flight") { model.bootstrapInFlight }+        try mark(configuration)++        let outcome = await model.runBackgroundExport()+        gate.release()+        await boot.value++        #expect(outcome == .skipped(.unavailable("the library is opening")))+        #expect(model.state == .ready)+        #expect(log.concurrentHighWaterMark == 1)+        #expect(model.backgroundSession == nil, "the skipped pass opened nothing")+    }++    @Test("Two callers share one pass, and either one's cancellation expires it")+    func concurrentCallersShareOnePass() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        try mark(configuration)++        let mock = MockLibraryProvider()+        let model = AppLibraryModel(readyRepository: mock, configuration: configuration)+        model.startSyncObservation(+            configuration: configuration,+            mirroring: .attached(containerID: Self.fixtureContainerID))++        let first = Task { await model.runBackgroundExport() }+        await waitUntil("the first caller to start the pass") { model.backgroundExportTask != nil }+        let second = Task { await model.runBackgroundExport() }+        await waitUntil("the pass to take the live library") { model.backgroundPassHoldsLibrary }+        // The second caller has to have joined before the cancellation lands, or+        // it would be starting a pass of its own rather than sharing this one.+        // A sleep rather than a yield spin: this suite runs beside every other+        // main-actor suite, and holding the queue starves them.+        try? await Task.sleep(for: .milliseconds(20))++        first.cancel()+        let outcomeOfFirst = await first.value+        let outcomeOfSecond = await second.value++        #expect(outcomeOfFirst == .expired, "any joiner's cancellation expires the shared pass")+        #expect(outcomeOfSecond == outcomeOfFirst, "and both callers are told the same thing")+        #expect(model.backgroundExportTask == nil, "the handle is cleared as the pass's last act")+    }+    // MARK: - Foreground clearing (Req 2.4, Q23)++    /// One completed export, as the mirror would report it.+    private func exportEvent(startedAt: Date, succeeded: Bool = true) -> SyncEvent {+        SyncEvent(+            type: .exportEvent, endDate: startedAt.addingTimeInterval(1),+            succeeded: succeeded, startDate: startedAt)+    }++    /// The status file the monitor reads at construction. Written through the+    /// same plain `JSONEncoder` the package writes it with — the record itself+    /// is public, the file helper is not.+    private func writeStatus(+        _ record: SyncStatusRecord, to configuration: LibraryConfiguration+    ) throws {+        try FileManager.default.createDirectory(+            at: configuration.rootDirectory, withIntermediateDirectories: true)+        try JSONEncoder().encode(record).write(to: configuration.syncStatusURL)+    }++    /// A model over a mirroring root, opened. The container factory is the+    /// counting stand-in, so the open reports `.attached` and the monitor exists.+    private func bootstrappedModel(+        _ configuration: LibraryConfiguration+    ) async -> AppLibraryModel {+        let model = AppLibraryModel(configuration: configuration)+        model.mirroringOpenHooks = countingHooks(ContainerFactoryLog())+        await model.bootstrap()+        return model+    }++    /// Waits for the foreground arm to be parked on the monitor.+    ///+    /// Arming is a `Task`, so the waiter registers one main-actor hop after the+    /// call that armed it. A test that fed the monitor before that hop would be+    /// asserting nothing at all — the event would arrive with no waiter to+    /// resolve, and the wait that follows would run to its timeout.+    private func waitForArm(on monitor: SyncMonitor) async {+        await waitUntil("the foreground clearing to park on the monitor") {+            monitor.pendingExportWaiterCount == 1+        }+    }++    @Test("A marker an earlier export already carried is cleared at the open, with no wait")+    func aSettledMarkerIsClearedAtBootstrap() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        try mark(configuration)+        // The persisted half of the rule (Q24): an export started after that+        // marker was written, while nobody was watching.+        try writeStatus(+            SyncStatusRecord(lastExportStarted: Date().addingTimeInterval(60)),+            to: configuration)++        let model = await bootstrappedModel(configuration)++        #expect(model.state == .ready)+        #expect(try pendingMarkers(configuration).isEmpty)+    }++    @Test("A marker no export has carried is cleared when the mirror reports a later one")+    func anOutstandingMarkerIsClearedByALaterExport() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        try mark(configuration)++        let model = await bootstrappedModel(configuration)+        let monitor = try #require(model.syncMonitor)+        #expect(try pendingMarkers(configuration).count == 1, "nothing has exported it yet")+        await waitForArm(on: monitor)++        monitor.observe(exportEvent(startedAt: Date().addingTimeInterval(60)))++        await waitUntil("the foreground arm to clear the marker") {+            ((try? self.pendingMarkers(configuration)) ?? ["not read"]).isEmpty+        }+    }++    @Test("An export that started before the marker leaves it alone (Q8)")+    func anEarlierExportLeavesTheMarker() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        let name = try mark(configuration)++        let model = await bootstrappedModel(configuration)+        let monitor = try #require(model.syncMonitor)+        await waitForArm(on: monitor)++        // An export enqueued before the commit proves nothing about it.+        monitor.observe(exportEvent(startedAt: .distantPast))+        try? await Task.sleep(for: .milliseconds(50))++        #expect(try pendingMarkers(configuration) == [name])+    }++    @Test("Every activation re-arms, so a marker written since the last one is picked up (Q23)")+    func anActivationReArmsTheClearing() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)++        // Nothing to arm on at the open: the marker arrives afterwards, from a+        // share while the app was already running.+        let model = await bootstrappedModel(configuration)+        let monitor = try #require(model.syncMonitor)+        try mark(configuration)++        await model.drainAndReconcile()+        await waitForArm(on: monitor)+        monitor.observe(exportEvent(startedAt: Date().addingTimeInterval(60)))++        await waitUntil("the re-armed clearing to take the new marker") {+            ((try? self.pendingMarkers(configuration)) ?? ["not read"]).isEmpty+        }+    }++    @Test("A teardown cancels the arm: an export observed afterwards clears nothing")+    func teardownCancelsTheArm() async throws {+        let root = tempRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = configuration(mirroring: true, at: root)+        let name = try mark(configuration)++        let model = await bootstrappedModel(configuration)+        let released = try #require(model.syncMonitor)+        await waitForArm(on: released)++        // A re-bootstrap tears the first library down, and the arm parked on+        // that library's monitor goes with it (Q43's discipline).+        await model.bootstrap()+        #expect(model.syncMonitor !== released, "the second open built a monitor of its own")++        released.observe(exportEvent(startedAt: Date().addingTimeInterval(60)))+        try? await Task.sleep(for: .milliseconds(50))++        #expect(+            try pendingMarkers(configuration) == [name],+            "a cancelled arm clears nothing, whatever the monitor it left behind reports")+    }+}
Asterism/AsterismTests/BackgroundExportSchedulerTests.swift Added +138 / -0
diff --git a/Asterism/AsterismTests/BackgroundExportSchedulerTests.swift b/Asterism/AsterismTests/BackgroundExportSchedulerTests.swiftnew file mode 100644index 0000000..ce8bc8f--- /dev/null+++ b/Asterism/AsterismTests/BackgroundExportSchedulerTests.swift@@ -0,0 +1,138 @@+#if os(iOS)+import AsterismCore+import BackgroundTasks+import Foundation+import Testing++@testable import Asterism++/// Req 3.1's "a request is always pending" and Req 3.3's "a refusal is logged+/// once per launch", proven through the submitter seam.+///+/// The one thing these tests may never do is spell the composed identifier out.+/// It is `$(ASTERISM_IDENTITY).backgroundExport`, derived from the same token as+/// the App Group, and `verify-identity`'s check 5 sweeps the tracked tree for+/// exactly that literal — so the assertions are on the *suffix* and on the+/// bundle key the value came from, never on the value itself.+@Suite("Background export scheduler")+@MainActor+struct BackgroundExportSchedulerTests {++    /// A submitter that records what it was handed, and optionally refuses.+    ///+    /// A class rather than a captured local: the closure the scheduler holds+    /// escapes, and the test reads the recording after `submit()` returns.+    private final class RecordingSubmitter {+        private(set) var requests: [BGAppRefreshTaskRequest] = []+        var refusal: Error?++        func submit(_ request: BGAppRefreshTaskRequest) throws {+            requests.append(request)+            if let refusal { throw refusal }+        }+    }++    private struct Refused: Error {}++    // MARK: - The request++    @Test("submit() hands the submitter one request per call")+    func submitCallsTheSubmitterOncePerCall() {+        let submitter = RecordingSubmitter()+        let scheduler = BackgroundExportScheduler(submitter: submitter.submit)++        scheduler.submit()+        #expect(submitter.requests.count == 1)++        scheduler.submit()+        #expect(submitter.requests.count == 2, "Each call submits again; the system replaces the pending request")+    }++    @Test("the request carries the declared identifier")+    func requestCarriesTheDeclaredIdentifier() throws {+        let submitter = RecordingSubmitter()+        let scheduler = BackgroundExportScheduler(submitter: submitter.submit)++        scheduler.submit()++        let request = try #require(submitter.requests.first)+        #expect(request.identifier == BackgroundExportScheduler.identifier)+    }++    /// Req 3.2, and Decision 2's "no `earliestBeginDate`": the feature exists to+    /// remove latency, so the request asks for the next grant the system has+    /// rather than deferring itself. An app-refresh request has no+    /// external-power condition to set, which is the other half of Req 3.2.+    @Test("the request defers itself to no earliest date")+    func requestHasNoEarliestBeginDate() throws {+        let submitter = RecordingSubmitter()+        let scheduler = BackgroundExportScheduler(submitter: submitter.submit)++        scheduler.submit()++        let request = try #require(submitter.requests.first)+        #expect(request.earliestBeginDate == nil)+    }++    // MARK: - Refusal (Req 3.3)++    @Test("a refusing submitter never throws out of submit(), and is logged once")+    func aRefusalIsLoggedOnceAndSwallowed() {+        let submitter = RecordingSubmitter()+        submitter.refusal = Refused()+        let scheduler = BackgroundExportScheduler(submitter: submitter.submit)++        for _ in 0..<5 { scheduler.submit() }++        #expect(submitter.requests.count == 5, "Every call still submits; only the logging is once")+        #expect(+            scheduler.loggedRefusals == 1,+            """+            Background App Refresh being off refuses every submit for the life \+            of the process; one line per launch is the whole of Req 3.3+            """)+    }++    @Test("a scheduler that has not been refused has logged nothing")+    func noRefusalLogsNothing() {+        let submitter = RecordingSubmitter()+        let scheduler = BackgroundExportScheduler(submitter: submitter.submit)++        scheduler.submit()++        #expect(scheduler.loggedRefusals == 0)+    }++    // MARK: - The identifier's provenance++    /// The declaration chain, end to end through a real build, in the shape+    /// `AsterismTests.bundleCarriesDerivedIdentityKeys` uses for the App Group:+    /// this bundle is hosted in the app, so `Bundle.main` is the built+    /// Development app.+    @Test("the identifier is the bundle's declared value, expanded")+    func identifierComesFromTheBundleKey() throws {+        let raw = Bundle.main.object(+            forInfoDictionaryKey: BackgroundExportScheduler.infoPlistKey)+        let declared = try #require(+            raw as? String,+            "\(BackgroundExportScheduler.infoPlistKey) is missing from the hosted app's Info.plist")++        #expect(declared == BackgroundExportScheduler.identifier)+        #expect(!declared.contains("$("), "The build setting was not expanded")+        // The suffix only. The stem is the identity token, and the literal+        // sweep in `verify-identity` refuses to find it written down here.+        #expect(declared.hasSuffix(".backgroundExport"))+    }++    /// Q22: the identifier is per-bundle, so it shares the App Group's stem. A+    /// build that submitted the other configuration's task would be a build the+    /// system never grants — and this is the assertion that would catch it.+    @Test("the identifier and the App Group derive from the same token")+    func identifierSharesTheIdentityStem() throws {+        let appGroup = try LibraryConfiguration.declaredAppGroupIdentifier(in: .main)+        let stem = appGroup.dropFirst("group.".count)++        #expect(BackgroundExportScheduler.identifier == "\(stem).backgroundExport")+    }+}+#endif
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Modified +6 / -0
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex 78272fa..c7ef790 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -35,6 +35,12 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     var updateWorkResult: Result<LibraryWriteOutcome, Error> = .success(.committed)     var moveEntryResult: Result<LibraryWriteOutcome, Error> = .success(.committed)     var workDestinationsResult: Result<[WorkSnapshot], Error> = .success([])+    /// Q46's flag, as the background-export pass reads it (Req 1.10). Named+    /// apart from the protocol method it answers because Swift will not take a+    /// stored property and a method of the same name on one type.+    var bulkOperationInProgress = false++    func isBulkOperationInProgress() async -> Bool { bulkOperationInProgress }      // MARK: - Captured arguments 
Asterism/AsterismTests/PlatformSeamTests.swift Modified +25 / -7
diff --git a/Asterism/AsterismTests/PlatformSeamTests.swift b/Asterism/AsterismTests/PlatformSeamTests.swiftindex 58e50a8..31af792 100644--- a/Asterism/AsterismTests/PlatformSeamTests.swift+++ b/Asterism/AsterismTests/PlatformSeamTests.swift@@ -40,13 +40,31 @@ struct PlatformSeamTests {         "Support/AppLifecycle.swift",     ] -    /// The two files that are iOS-only in their entirety: a UIKit bridge each,-    /// wrapped whole in `#if os(iOS)`. They are the only files allowed to-    /// `import UIKit`, and the whole-file wrap is the only conditional they may-    /// carry — a second `#if` inside one of them would make it a third seam.+    /// The files that are iOS-only in their entirety, wrapped whole in+    /// `#if os(iOS)`. The whole-file wrap is the only conditional any of them+    /// may carry — a second `#if` inside one would make it another seam.+    ///+    /// Two are UIKit bridges. The third, `BackgroundExportScheduler`, wraps a+    /// framework that does not exist on the Mac at all: `BackgroundTasks`+    /// (background-export design §Scheduling). The Mac's share of that feature+    /// is the foreground clearing arm on `AppLibraryModel`, which compiles+    /// everywhere and carries no conditional (background-export Q16). A file+    /// whose whole subject is absent on one platform is the one case the wrap+    /// is for; anything less than the whole file belongs at a named seam.     private static let iOSOnlyFiles: Set<String> = [         "Views/ShareSheet.swift",         "Views/BackupDocumentPicker.swift",+        "Support/BackgroundExportScheduler.swift",+    ]++    /// The two UIKit bridges, and the only files allowed to `import UIKit`.+    ///+    /// A strict subset of `iOSOnlyFiles`, and separate from it on purpose:+    /// being iOS-only is not a licence to reach for UIKit. Widening the wrap+    /// list must not widen this one.+    private static let uiKitBridgeFiles: Set<String> = [+        "Views/ShareSheet.swift",+        "Views/BackupDocumentPicker.swift",     ]      /// The iOS share extension's source directory.@@ -146,10 +164,10 @@ struct PlatformSeamTests {             "Platform conditionals outside the design's seams:\n\(offenders.joined(separator: "\n"))")     } -    @Test("import UIKit appears only in the two iOS-only files")+    @Test("import UIKit appears only in the two UIKit bridge files")     func uiKitImportedOnlyInIOSOnlyFiles() throws {         var offenders: [String] = []-        for path in try Self.appSourceFiles() where !Self.iOSOnlyFiles.contains(path) {+        for path in try Self.appSourceFiles() where !Self.uiKitBridgeFiles.contains(path) {             for line in try Self.lines(of: path)             where line.trimmingCharacters(in: .whitespaces) == "import UIKit" {                 offenders.append(path)@@ -157,7 +175,7 @@ struct PlatformSeamTests {         }         #expect(             offenders.isEmpty,-            "import UIKit outside the iOS-only files: \(offenders.joined(separator: ", "))")+            "import UIKit outside the UIKit bridge files: \(offenders.joined(separator: ", "))")     }      // MARK: - The share extension's own seam (N4)
Asterism/AsterismUITests/BackgroundExportSettingsUITests.swift Added +52 / -0
diff --git a/Asterism/AsterismUITests/BackgroundExportSettingsUITests.swift b/Asterism/AsterismUITests/BackgroundExportSettingsUITests.swiftnew file mode 100644index 0000000..b787e1c--- /dev/null+++ b/Asterism/AsterismUITests/BackgroundExportSettingsUITests.swift@@ -0,0 +1,52 @@+import XCTest++/// Req 4.2: a `Development` build offers a control that runs the same pass the+/// scheduler would, and shows its outcome inline.+///+/// A background grant cannot be exercised on a simulator in any meaningful way,+/// so what this journey proves is the part that does not need one: the row is+/// reachable through real navigation, tapping it runs a pass, and the pass's own+/// outcome comes back to the screen. The UI-test root has never been captured+/// into, so there is no `ExportOwed/` directory and the pass ends at the first+/// thing it does — reading the marker area — without opening anything (Req 2.2).+/// Everything a grant is needed for is the runbook's.+///+/// The other half of Req 4.2 — that `Personal` shows **no** control — has no+/// test here: that scheme carries no test bundle at all, so it is by inspection+/// of the `#if DEBUG` gate on the model and on the row (Q17).+final class BackgroundExportSettingsUITests: XCTestCase {+    let app = XCUIApplication()++    override func setUp() {+        continueAfterFailure = false+        XCUIDevice.shared.orientation = .portrait+        terminateAndWaitForExit(app)+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launch()+    }++    override func tearDown() {+        terminateAndWaitForExit(app)+    }++    func testTheDebugTriggerRunsAPassAndReportsItsOutcome() {+        waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+        waitFor(app.buttons["settings-button"], "Recent carries the Settings route").tap()+        waitFor(app.anyElement("settings-view"), "Settings opens")++        // The row lives inside the collapsed Debug disclosure, beside the sync+        // lines and Check Library.+        expandSettingsDebug(witness: "settings-background-export-run", in: app)+        scrollUntilTappableAndTap(+            app.buttons["settings-background-export-run"], in: app,+            "The Debug section offers the background-export trigger")++        let result = app.anyElement("settings-background-export-result")+        waitFor(result, "The trigger reports the pass's outcome inline")+        XCTAssertTrue(+            result.label.localizedCaseInsensitiveContains("no marker"),+            "A root nothing has ever been shared into owes no export; the row read "+                + "\"\(result.label)\"")+    }+}
CHANGELOG.md Modified +102 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex e11298f..f917ea9 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,108 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- **Pre-push review fixes for background-export (T-2052).** An+  activation after a pass had deferred arrivals ran the diagnosis and+  snapshot refresh twice, and an ordinary arrival never cleared the+  deferral flag; `drainAndReconcile()` now reconciles the deferred+  arrival, drains, and refreshes once. The scheduler reads its task+  identifier through the now-public+  `LibraryConfiguration.declaredIdentifier(forKey:operation:in:)` instead+  of a copy of that ladder; one `Duration.timeInterval` replaces three+  private conversions; a pass resolves its configuration once and hands+  it to the cold open; the live half of the settlement rule is one+  `BackgroundExportPass.awaitExportAndClear` shared by the pass and the+  foreground arm; and a grant turned away before a pass exists still+  logs its two lines (Q40). The identity lint's single-reference array+  check is one function with two callers.++- **Background export is documented and its device runbook written+  (background-export, phase "Documentation", T-2052 — spec complete).**+  `specs/background-export/runbook.md` carries the eight device steps+  the host cannot prove: the Development trigger and foreground+  clearing, the resident path by simulated launch (a `timed out` there+  goes back to design, Q29), simulated expiration, a cold launch on a+  real grant, Background App Refresh off, the marker's protection class+  on device, the second-install arrival with the early-settlement watch+  (Q31), and a final `Personal` observation after a container download.+  Every step is a device run under `CLAUDE.md`'s approval rule and the+  runbook is the owner's to run. `specs/cloudkit-mirroring/prerequisites.md`+  now points at this spec as the closing of the extension-capture+  latency gap, `CLAUDE.md` notes the Development-only Settings trigger+  and the runbook, and the specs overview marks the feature Done.++- **The share extension leaves the export-owed marker (background-export,+  phase "Extension", T-2052).** `ShareCaptureSession` builds an+  `ExportOwedMarker` in `bootstrap()` step 1 from the same resolved+  configuration as the spool and, in `finish`, writes one marker after a+  committed capture's spool record is discarded and before the extension+  completes (Req 2.1) — after the commit is durable and never before it,+  which is the settlement rule's precondition (design §The marker). A+  failed write is logged under `category:BackgroundExport` and ignored;+  the capture completes as it does today (Req 2.5). The extension still+  never mirrors (Req 2.7). Both extension targets compile the same file;+  the Mac app's foreground arm clears the Mac extension's markers. No+  host test reaches `finish` — the extension source is not compiled into+  the unit bundle — so the runbook's device pass covers the write.++- **The app runs the background export pass, requests the grant, and+  clears markers in the foreground (background-export, phase "App",+  T-2052).** `AppLibraryModel.runBackgroundExport()` is the library+  session the pass runs against (Decision 1): it reuses the resident+  repository and monitor when the app is ready, skips when the library+  is unavailable, opening, or mid-bulk-operation, and on a cold launch+  starts a `SyncMonitor` before `openForApp` and holds both in a+  `backgroundSession` that never touches `repository` or `state`, so a+  scene-less pass leaves nothing behind when it releases (Req 1.5, 3.5).+  Concurrent callers join one pass through a cancellation handler, so+  any joiner's cancellation expires it (Q30); `bootstrap()` sets+  `bootstrapInFlight` before its first suspension and drains the pass,+  so the two openers exclude each other by construction (Req 1.8) and+  the foreground pre-empts rather than waits (Req 1.7). Arrivals during+  a resident pass are deferred to the next activation.+  `armExportOwedClearing()` applies both halves of the settlement rule+  after `bootstrap()` and on every activation (Req 2.4, Q23) and is the+  Mac's whole share of the feature (Q16). Both configurations declare+  `$(ASTERISM_IDENTITY).backgroundExport` as the task identifier, the+  app plist permits it and adds `fetch` to `UIBackgroundModes`, and+  `make verify-identity` lints all of it (Req 3.4, Q22, Q37).+  `BackgroundExportScheduler` (iOS only) submits the refresh request+  with no earliest date and logs a refusal once per process (Req 3.1–3.3,+  Decision 2); `AsterismApp` registers the `.appRefresh` handler,+  submitting before and after each pass and on+  `scenePhase == .background` (Q21, Q38). `Development` builds get a+  "Run background export" row in Settings' Debug disclosure that runs+  the same pass and shows its outcome (Req 4.2; Q34, Q35). Fifteen new+  `AppLibraryModel` tests, the scheduler tests, a UI test for the row,+  and a lint self-test recorded in the task-5 commit body.++- **The background-export pass and its rule live in the package+  (background-export, phase "Core", T-2052).** `ExportOwedMarker` is the+  App Group directory of empty UUID-named files the share extension+  will leave after a commit and the app clears after an export it+  observed (Decision 3); directory and files are created on the+  first-unlock protection class (Q18), a missing directory reads as+  "nothing owed", and any other listing failure is rethrown so a+  protected container is "unavailable", never "no marker" (Req 2.6).+  `SyncMonitor` learns the settlement rule in both halves (Q24):+  `SyncEvent.startDate` and a persisted `SyncStatusRecord.lastExportStarted`+  recorded only on a successful export and only forward, and+  `awaitExport(startedAfter:deadline:)`, a cancellable wait with+  id-keyed waiters, one sleeper per deadline, and a single+  remove-then-resume resolver so an event, the deadline, `stop()` and+  the cancellation hop cannot resume a continuation twice (Q30).+  `observe(_:)` is `ingest`'s public spelling (Q19).+  `LibraryProviding.isBulkOperationInProgress()` answers Q46's flag from+  the repository actor. `BackgroundExportPass` is the pass itself —+  list, settle, acquire, wait, clear only the names it listed, release+  on every acquired path — with its outcomes, the 20 s budget (Q20),+  the `BackgroundExportSession` protocol the app will supply, and a+  `category:BackgroundExport` log whose two lines share one pass+  identifier (Req 4.1; Q32, Q33 record the log line's shape and where+  a failure's message comes from). Proven on the host by+  `ExportOwedMarkerTests`, `BackgroundExportPassTests` and eleven new+  `SyncMonitorTests` cases; nothing in the app calls it yet.+ - **Three defects from the first real Mac run (ipad-and-mac-layouts,   T-2286).** The detail column follows a second selection — the entry   routes were identity-stable, so `EntryDetailView`'s `@State` models
CLAUDE.md Modified +10 / -0
diff --git a/CLAUDE.md b/CLAUDE.mdindex aae8f3a..ad318ed 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -73,6 +73,16 @@ bar. `make verify-identity` (a `test-core` prerequisite) is an identity lint, not a style one: it checks the App Group / CloudKit identifier declarations without building anything — see `specs/configuration-identity/`. +`Development` builds carry a **Run background export** button in Settings,+inside the collapsed Debug disclosure: it runs one background-export pass on+demand and reports the outcome inline (`specs/background-export/` Req 4.2).+`Personal` has neither the button nor the type behind it. Background grants+cannot be exercised on the simulator at all, so everything else about that+feature is verified by hand on a phone — `specs/background-export/runbook.md`,+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.+ ### Configurations are not interchangeable  | Configuration | Scheme | Bundle ID | App Group | CloudKit container | Optimization |
Packages/AsterismCore/Sources/AsterismCore/BackgroundExportPass.swift Added +353 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackgroundExportPass.swift b/Packages/AsterismCore/Sources/AsterismCore/BackgroundExportPass.swiftnew file mode 100644index 0000000..de3027d--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackgroundExportPass.swift@@ -0,0 +1,353 @@+import Foundation+import OSLog++/// How a pass ended (Req 4.1).+public enum BackgroundExportOutcome: Equatable, Sendable {+    /// An export that began after the newest outstanding marker finished+    /// without error. The listed markers were cleared.+    case exported+    /// The library was never taken.+    case skipped(SkipReason)+    /// The budget ran out with nothing observed — nothing was pending, or+    /// CloudKit deferred the work. The marker survives.+    case timedOut+    /// The system cancelled the grant.+    case expired+    /// A foreground open cancelled the pass (Q30). Distinguished from `.expired`+    /// so the log can tell the reader opening the app from an expiry.+    case preempted+    /// An export or a setup event failed after the threshold: the mirror said it+    /// will not export now (Q27). The marker survives.+    case failed(String)++    /// Why a pass ended without waiting for an export.+    public enum SkipReason: Equatable, Sendable {+        /// Nothing has been captured since the last clearing (Req 2.2).+        case noMarker+        /// Every marker was older than the recorded export start, so an export+        /// has already carried them (Q24, Q25). They were cleared.+        case alreadyExported+        /// The library could not be read or opened, with the diagnosis+        /// (Req 1.9, 2.6). The marker survives.+        case unavailable(String)+        /// An import or a reconciliation is running, and is exporting on its own+        /// as it saves (Req 1.10, Q46).+        case bulkOperation+    }++    /// One short line per case, for the Console record and the Development+    /// trigger (Req 4.1). No reader content — a pass carries none — so it is+    /// safe to log publicly.+    public var logDescription: String {+        switch self {+        case .exported:+            return "exported"+        case .skipped(.noMarker):+            return "skipped (no marker)"+        case .skipped(.alreadyExported):+            return "skipped (already exported)"+        case .skipped(.unavailable(let reason)):+            return "skipped (library unavailable: \(reason))"+        case .skipped(.bulkOperation):+            return "skipped (bulk operation in progress)"+        case .timedOut:+            return "timed out"+        case .expired:+            return "expired"+        case .preempted:+            return "preempted"+        case .failed(let message):+            return "failed: \(message)"+        }+    }+}++/// The pass's one timing constant.+public enum BackgroundExportBounds {+    /// 20 s from the moment the pass starts (Q20).+    ///+    /// A refresh grant is about 30 s; the open is budgeted at 2 s+    /// (cloudkit-mirroring Req 9.1, plus up to the 5 s lock timeout if the+    /// extension is mid-capture) and the shutdown is synchronous, so the+    /// remaining margin covers both. This deadline is the only thing that ends a+    /// pass whose mirror has nothing to say: a store with nothing pending emits+    /// no export event at all.+    public static let passBudget: Duration = .seconds(20)+}++/// What the library session handed back.+public enum BackgroundExportAcquisition {+    /// The library is available, and this is the monitor watching its mirror.+    case monitor(SyncMonitor)+    case skipped(BackgroundExportOutcome.SkipReason)+    /// The acquisition itself was cancelled — the grant expiring, or a+    /// foreground `bootstrap()` pre-empting the pass.+    case cancelled+}++/// The library the pass runs against, which only the app can provide+/// (Decision 1): it reuses a live repository when the app is resident and opens+/// one of its own on a cold launch.+public protocol BackgroundExportSession: AnyObject {+    @MainActor func acquire() async -> BackgroundExportAcquisition+    /// Idempotent, and called on every path after a successful `acquire()`.+    @MainActor func release() async+    /// Whether the cancellation the pass is seeing came from a foreground+    /// `bootstrap()` rather than the system.+    @MainActor var isPreempting: Bool { get }+}++/// One line of the pass's record (Req 4.1). No reader content — a pass carries+/// none — so every field is logged publicly.+public struct BackgroundExportLogLine: Equatable, Sendable {+    public enum Phase: String, Sendable {+        case start+        case end+    }++    public let phase: Phase+    /// The same identifier on both lines of one pass, so a Console trace can+    /// pair them across a cold launch.+    public let passID: UUID+    /// How many markers the pass found.+    public let listed: Int+    /// How many of those the persisted export start settled at listing time.+    public let settled: Int+    /// Set on the end line only.+    public let outcome: BackgroundExportOutcome?+    /// Set on the end line only, on the pass's own clock.+    public let elapsed: Duration?++    public init(+        phase: Phase, passID: UUID, listed: Int, settled: Int,+        outcome: BackgroundExportOutcome? = nil, elapsed: Duration? = nil+    ) {+        self.phase = phase+        self.passID = passID+        self.listed = listed+        self.settled = settled+        self.outcome = outcome+        self.elapsed = elapsed+    }+}++/// The production sink: `subsystem:me.nore.ig.Asterism category:BackgroundExport`+/// (Req 4.1, Q5).+public enum BackgroundExportLog {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackgroundExport")++    public static func emit(_ line: BackgroundExportLogLine) {+        switch line.phase {+        case .start:+            logger.info("""+                Background export \(line.passID.uuidString, privacy: .public) started: \+                \(line.listed, privacy: .public) marker(s) found, \+                \(line.settled, privacy: .public) already exported+                """)+        case .end:+            // Both fields are set on an end line. The fallbacks are there so a+            // malformed line still reads as a line rather than as `Optional(…)`.+            let outcome = line.outcome?.logDescription ?? "unknown"+            let elapsed = line.elapsed.map { String(format: "%.3f s", $0.timeInterval) } ?? "?"+            logger.info("""+                Background export \(line.passID.uuidString, privacy: .public) ended: \+                \(outcome, privacy: .public) after \(elapsed, privacy: .public)+                """)+        }+    }+}++/// The pass: settle, take the library, wait for the mirror, clear, release+/// (Req 1.2–1.5).+///+/// Every decision it makes is here rather than in the app, so `make test-core`+/// proves them against a fake session (Decision 1). The pass opens nothing+/// itself and writes nothing to the store.+public struct BackgroundExportPass {+    private let marker: ExportOwedMarker+    private let statusURL: URL+    private let clock: any RepositoryClock+    private let budget: Duration+    private let log: @Sendable (BackgroundExportLogLine) -> Void++    public init(+        marker: ExportOwedMarker,+        statusURL: URL,+        clock: any RepositoryClock = SystemRepositoryClock(),+        budget: Duration = BackgroundExportBounds.passBudget,+        log: @escaping @Sendable (BackgroundExportLogLine) -> Void = BackgroundExportLog.emit+    ) {+        self.marker = marker+        self.statusURL = statusURL+        self.clock = clock+        self.budget = budget+        self.log = log+    }++    @MainActor+    public func run(session: any BackgroundExportSession) async -> BackgroundExportOutcome {+        let passID = UUID()+        let startedAt = clock.now()++        func finish(+            _ outcome: BackgroundExportOutcome, listed: Int, settled: Int+        ) -> BackgroundExportOutcome {+            log(BackgroundExportLogLine(+                phase: .end, passID: passID, listed: listed, settled: settled,+                outcome: outcome,+                elapsed: .seconds(clock.now().timeIntervalSince(startedAt))))+            return outcome+        }++        let listed: [ExportOwedMarker.Entry]+        do {+            listed = try marker.pending()+        } catch {+            // Req 2.6: a container protected before first unlock reads as+            // "unavailable", never as "no marker".+            log(BackgroundExportLogLine(phase: .start, passID: passID, listed: 0, settled: 0))+            let reason = (error as? ExportOwedMarkerError)?.reason ?? error.localizedDescription+            return finish(.skipped(.unavailable(reason)), listed: 0, settled: 0)+        }++        let status = SyncStatusFile.read(from: statusURL)+        let outstanding = Self.settle(listed, against: status, marker: marker)+        let settled = listed.count - outstanding.count+        log(BackgroundExportLogLine(+            phase: .start, passID: passID, listed: listed.count, settled: settled))++        guard !outstanding.isEmpty else {+            // Both arms open nothing. They are kept apart because "an export+            // already carried it" is the case the runbook most needs to see+            // (Q25).+            let outcome: BackgroundExportOutcome =+                listed.isEmpty ? .skipped(.noMarker) : .skipped(.alreadyExported)+            return finish(outcome, listed: listed.count, settled: settled)+        }++        let monitor: SyncMonitor+        switch await session.acquire() {+        case .monitor(let acquired):+            monitor = acquired+        case .skipped(let reason):+            // Nothing was acquired, so there is nothing to release.+            return finish(.skipped(reason), listed: listed.count, settled: settled)+        case .cancelled:+            return finish(+                session.isPreempting ? .preempted : .expired,+                listed: listed.count, settled: settled)+        }++        // The expiry can land while the open was under way. Checked before the+        // wait so a cancelled pass releases at once rather than parking.+        if Task.isCancelled {+            let outcome: BackgroundExportOutcome = session.isPreempting ? .preempted : .expired+            await session.release()+            return finish(outcome, listed: listed.count, settled: settled)+        }++        // The live half of the rule, and the clearing that goes with it: only+        // the names this pass listed, and only on `.exported` (Req 2.3).+        let deadline = startedAt.addingTimeInterval(budget.timeInterval)+        let waited = await Self.awaitExportAndClear(+            outstanding, on: monitor, marker: marker, deadline: deadline)++        let outcome: BackgroundExportOutcome+        switch waited {+        case .exported:+            outcome = .exported+        case .failed:+            outcome = .failed(+                monitor.status.lastFailure?.message ?? "the mirror reported a failed event")+        case .deadline:+            outcome = .timedOut+        case .cancelled:+            outcome = session.isPreempting ? .preempted : .expired+        case .stopped:+            // The monitor only stops when something tore the library down under+            // the pass, which is the foreground taking it back.+            outcome = .preempted+        }++        // Req 1.5: released before the outcome is reported, on every path that+        // acquired.+        await session.release()+        return finish(outcome, listed: listed.count, settled: settled)+    }++    /// The persisted half of the settlement rule, shared with the foreground arm+    /// (Q24): clears every marker created before the latest successful export+    /// started, and returns the ones still owed.+    ///+    /// The comparison is strict in the same direction as the live half: a marker+    /// is settled by an export whose start is *later* than the marker's+    /// creation.+    public static func settle(+        _ entries: [ExportOwedMarker.Entry],+        against status: SyncStatusRecord,+        marker: ExportOwedMarker+    ) -> [ExportOwedMarker.Entry] {+        guard let exportStarted = status.lastExportStarted else { return entries }+        var settled: [String] = []+        var outstanding: [ExportOwedMarker.Entry] = []+        for entry in entries {+            if entry.createdAt < exportStarted {+                settled.append(entry.name)+            } else {+                outstanding.append(entry)+            }+        }+        guard !settled.isEmpty else { return entries }+        marker.clear(settled)+        return outstanding+    }++    /// The **live** half of the settlement rule, shared with the foreground arm+    /// beside ``settle(_:against:marker:)``: park on the monitor until an export+    /// that began after the newest outstanding marker finishes, and clear+    /// exactly those names when one does.+    ///+    /// The threshold is the newest `createdAt` rather than the oldest, so an+    /// export already in flight when the wait began counts only if it started+    /// after every marker it would have to carry. The names cleared are exactly+    /// the ones passed in: a marker written after the listing that produced them+    /// is not in the set and survives (Req 2.3).+    ///+    /// The wait's result is returned rather than interpreted. A pass maps it+    /// onto ``BackgroundExportOutcome`` and releases the library; the foreground+    /// arm, which holds no grant and passes no deadline, has nothing to map.+    @MainActor+    public static func awaitExportAndClear(+        _ outstanding: [ExportOwedMarker.Entry],+        on monitor: SyncMonitor,+        marker: ExportOwedMarker,+        deadline: Date?+    ) async -> ExportWaitResult {+        // Both callers establish that something is outstanding before they get+        // here; with nothing owed there is no threshold and nothing an export+        // could settle.+        guard let threshold = outstanding.map(\.createdAt).max() else { return .deadline }+        let result = await monitor.awaitExport(startedAfter: threshold, deadline: deadline)+        if result == .exported { marker.clear(outstanding.map(\.name)) }+        return result+    }++    /// Emits the two lines of a pass that never started (Req 4.1, Q32).+    ///+    /// The app declines a grant before it builds a pass on two arms — a+    /// `bootstrap()` already opening the library, and a configuration that+    /// cannot be resolved — and a grant that logs nothing is a grant a Console+    /// trace cannot account for. Both lines carry one fresh identifier, zero+    /// counts because nothing was listed, and no elapsed time because nothing+    /// ran.+    public static func reportUnstarted(+        _ outcome: BackgroundExportOutcome,+        log: @escaping @Sendable (BackgroundExportLogLine) -> Void = BackgroundExportLog.emit+    ) {+        let passID = UUID()+        log(BackgroundExportLogLine(phase: .start, passID: passID, listed: 0, settled: 0))+        log(BackgroundExportLogLine(+            phase: .end, passID: passID, listed: 0, settled: 0,+            outcome: outcome, elapsed: .zero))+    }+}
Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift Modified +16 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift b/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swiftindex a53fc55..a5de6fb 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Boundaries.swift@@ -6,6 +6,22 @@ public protocol RepositoryClock: Sendable {     func now() -> Date } +extension Duration {+    /// The duration as seconds, which is what `Date` arithmetic and+    /// `String(format:)` take.+    ///+    /// Beside `RepositoryClock` because that is what every caller is converting+    /// *for*: a budget expressed as a `Duration` turned into a deadline on the+    /// clock, or into a line in a log. `components` is+    /// (seconds, attoseconds), and the attosecond half is what a naive+    /// `Double(duration.components.seconds)` silently drops.+    var timeInterval: TimeInterval {+        let components = components+        return TimeInterval(components.seconds)+            + TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000+    }+}+ /// Resolves App Group containers outside persistence, enabling deterministic /// temporary-directory fixtures and fail-closed production resolution. public protocol SharedContainerLocating: Sendable {
Packages/AsterismCore/Sources/AsterismCore/ExportOwedMarker.swift Added +139 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ExportOwedMarker.swift b/Packages/AsterismCore/Sources/AsterismCore/ExportOwedMarker.swiftnew file mode 100644index 0000000..6a07ab2--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ExportOwedMarker.swift@@ -0,0 +1,139 @@+import Foundation++/// Failures of the marker directory, named so the pass can report *why* it could+/// not tell whether an export was owed (Req 2.6).+public enum ExportOwedMarkerError: Error, Equatable {+    /// The directory could not be created or listed.+    case fileSystem(operation: String, reason: String)++    public var reason: String {+        switch self {+        case .fileSystem(let operation, let reason): "\(operation): \(reason)"+        }+    }+}++/// The App Group marker directory: one empty file per committed capture, named+/// by a fresh UUID (Decision 3).+///+/// The extension writes a marker after every commit; the app lists the+/// directory, remembers the names it saw, and unlinks exactly those names after+/// it has observed an export that started later than the newest of them+/// (Req 2.1, 2.3). Creating and unlinking distinct names are each atomic at the+/// file system, so compare-and-clear needs no lock and no file content: a name+/// created after the listing is not in the listed set and survives whatever the+/// pass does.+///+/// The directory **and each file** are created with+/// `completeUntilFirstUserAuthentication` (Q18). A file takes its creator's+/// default protection class rather than its directory's, so the class is set at+/// both levels; a background grant usually arrives on a locked phone, and the+/// store itself is on that class already. The marker carries no reader content —+/// the files are empty and named by a UUID — so the spool's stricter+/// `completeUnlessOpen`, which would make the marker unreadable exactly when the+/// pass needs it, is not used.+public struct ExportOwedMarker: Sendable {+    /// One marker: its file name, and when the extension wrote it.+    ///+    /// The creation date is the marker's time, and the settlement rule compares+    /// it against an export's *start* (Q24). It is read from the file system+    /// rather than carried in the name so that a build change cannot alter what+    /// an installed library's markers mean.+    public struct Entry: Hashable, Sendable {+        public let name: String+        public let createdAt: Date++        public init(name: String, createdAt: Date) {+            self.name = name+            self.createdAt = createdAt+        }+    }++    /// `LibraryConfiguration.exportOwedURL`.+    public let directory: URL++    public init(directory: URL) {+        self.directory = directory+    }++    /// Leaves a marker saying an export is owed. The extension's call.+    ///+    /// Creates the directory on first use, because the extension can run before+    /// the app ever has. A throw is the extension's to ignore (Req 2.5); that it+    /// is reported at all is what makes the ignoring a decision.+    public func mark() throws {+        try createDirectoryIfNeeded()+        let url = directory.appending(path: UUID().uuidString)+        guard FileManager.default.createFile(+            atPath: url.path,+            contents: nil,+            attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]+        ) else {+            throw ExportOwedMarkerError.fileSystem(+                operation: "writing an export-owed marker",+                reason: "the file could not be created at \(url.path)")+        }+    }++    /// What is present, oldest first.+    ///+    /// A missing directory reads as empty — nothing has ever been captured+    /// through the extension, or every marker has been cleared. Any *other*+    /// listing failure is rethrown rather than answered as empty: a container+    /// that is protected before first unlock must be reported as "library+    /// unavailable", not as "no marker" (Req 2.6).+    public func pending() throws -> [Entry] {+        let contents: [URL]+        do {+            contents = try FileManager.default.contentsOfDirectory(+                at: directory,+                includingPropertiesForKeys: [.creationDateKey],+                options: [.skipsHiddenFiles])+        } catch let error as CocoaError where error.code == .fileReadNoSuchFile {+            return []+        } catch {+            throw ExportOwedMarkerError.fileSystem(+                operation: "listing the export-owed markers",+                reason: error.localizedDescription)+        }++        let entries = contents.map { url in+            Entry(+                name: url.lastPathComponent,+                createdAt: (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate+                    ?? Date.distantPast)+        }+        // Oldest first, with the name as the tiebreaker so two markers written+        // inside one file-system timestamp still have a stable order.+        return entries.sorted {+            $0.createdAt == $1.createdAt ? $0.name < $1.name : $0.createdAt < $1.createdAt+        }+    }++    /// Unlinks exactly these names. Everything else survives, including a marker+    /// written after the listing that produced them (Req 2.3).+    ///+    /// Non-throwing: a name that is already gone is the normal outcome of two+    /// passes racing, and a marker that could not be removed costs one pass that+    /// ends at its deadline — never a capture.+    public func clear(_ names: some Sequence<String>) {+        for name in names {+            try? FileManager.default.removeItem(at: directory.appending(path: name))+        }+    }++    /// `withIntermediateDirectories: true` already succeeds when the directory+    /// is there, so there is nothing to check first.+    private func createDirectoryIfNeeded() throws {+        do {+            try FileManager.default.createDirectory(+                at: directory,+                withIntermediateDirectories: true,+                attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])+        } catch {+            throw ExportOwedMarkerError.fileSystem(+                operation: "creating the export-owed marker directory",+                reason: error.localizedDescription)+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift Modified +33 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swiftindex 3e1dc08..db1cb06 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift@@ -101,6 +101,15 @@ public struct LibraryConfiguration: Sendable, Equatable {     /// that wrote it.     public static let pendingCaptureReportFilename = "report.json" +    // MARK: - Background export (background-export Decision 3)++    /// The export-owed marker area, beside the preserved-capture area.+    ///+    /// **Frozen persisted state**: a marker left by the extension is cleared by+    /// the app, and the two processes must agree on the path across releases,+    /// exactly as they do for `PendingCaptures`.+    public static let exportOwedDirectoryName = "ExportOwed"+     public let rootDirectory: URL      /// The CloudKit container this process mirrors into, or nil when mirroring@@ -170,6 +179,12 @@ public struct LibraryConfiguration: Sendable, Equatable {         rootDirectory.appending(path: Self.pendingCapturesDirectoryName)     } +    /// Where the extension says an export is owed and the app clears what it has+    /// seen exported (background-export Req 2.1).+    public var exportOwedURL: URL {+        rootDirectory.appending(path: Self.exportOwedDirectoryName)+    }+     public var pendingCapturesIncomingURL: URL {         pendingCapturesURL.appending(path: Self.pendingCapturesIncomingDirectoryName)     }@@ -300,6 +315,24 @@ public extension LibraryConfiguration {         try declaredMirroringContainerIdentifier(fromInfoDictionary: bundle.infoDictionary)     } +    /// Resolves any build-derived identifier a bundle declares, walking the same+    /// missing / not-a-string / empty / unexpanded ladder the App Group and+    /// container readers walk.+    ///+    /// Public so a key this package knows nothing about — the app's background+    /// export task identifier — is read by the one ladder rather than by a copy+    /// of it. `operation` is what the thrown+    /// ``LibraryRepositoryError/libraryUnavailable(operation:reason:)`` says the+    /// caller was doing; the reason names the key and what is wrong with it.+    static func declaredIdentifier(+        forKey key: String,+        operation: String,+        in bundle: Bundle+    ) throws -> String {+        try declaredIdentifier(+            forKey: key, operation: operation, fromInfoDictionary: bundle.infoDictionary)+    }+     private static func declaredIdentifier(         forKey key: String,         operation: String,
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Modified +13 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex 877946f..085a29c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -361,11 +361,24 @@ public protocol LibraryProviding: Sendable {     /// and is the caller: it awaits this before every re-`bootstrap()`. Default     /// no-op — a test double holds nothing to release.     func shutdown() async++    /// Whether an import or a reconciliation is running right now (Q46).+    ///+    /// Asked by the background-export pass before it takes the library+    /// (background-export Req 1.10): a bulk operation is exporting on its own as+    /// it saves, and a grant that neither starts nor interrupts one is a grant+    /// that has nothing to add. Answered by the repository actor so it is+    /// consistent with the flag `reconcileAfterSync` and the import set hold.+    ///+    /// Default false, like `shutdown()`: a test double runs no bulk operations.+    func isBulkOperationInProgress() async -> Bool }  public extension LibraryProviding {     func shutdown() async {} +    func isBulkOperationInProgress() async -> Bool { false }+     /// The production spelling of the export reads: the reader's own locale.     func entryExportInput(entryID: UUID) async throws -> EntryExportInput {         try await entryExportInput(entryID: entryID, locale: .current)
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +17 / -7
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 557d453..20cc7f8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -614,13 +614,6 @@ public actor LibraryRepository {         return outcome     } -    /// Whether a follow-up pass is owed, clearing the latch as it answers-    /// (Req 1.3).-    ///-    /// Consumed rather than read, so two callers cannot schedule two follow-ups-    /// for one deferral. The `bulkOperationInProgress` early return leaves the-    /// latch untouched, and the guard's caller re-reads after-    /// `refireDeferredReconcile`.     /// Puts a consumed latch back, for a caller that took it and then found it     /// could not act on it.     ///@@ -630,11 +623,28 @@ public actor LibraryRepository {         duplicateFollowUpNeeded = true     } +    /// Whether a follow-up pass is owed, clearing the latch as it answers+    /// (Req 1.3).+    ///+    /// Consumed rather than read, so two callers cannot schedule two follow-ups+    /// for one deferral. The `bulkOperationInProgress` early return leaves the+    /// latch untouched, and the guard's caller re-reads after+    /// `refireDeferredReconcile`.     public func takeDuplicateFollowUp() -> Bool {         defer { duplicateFollowUpNeeded = false }         return duplicateFollowUpNeeded     } +    /// Q46's flag, answered on the actor that owns it.+    ///+    /// The background-export pass reads this before it takes the library: an+    /// import or a reconciliation in flight is already saving, and therefore+    /// already exporting, so the pass neither starts nor interrupts one+    /// (background-export Req 1.10).+    public func isBulkOperationInProgress() -> Bool {+        bulkOperationInProgress+    }+     /// Runs the pass a trigger deferred while `bulkOperationInProgress` was held,     /// if there was one (Q46's second half).     ///
Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swift Modified +169 / -8
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swift b/Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swiftindex 2b10b84..d83298b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swift@@ -12,15 +12,43 @@ public struct SyncEvent: Sendable {     public var endDate: Date?     public var succeeded: Bool     public var error: NSError?+    /// When the mirror's activity **began** (background-export Q24).+    ///+    /// The settlement rule is stated against this rather than `endDate`: an+    /// export that started before a capture was committed can end after it, so+    /// an end date proves nothing about what the export carried. Optional+    /// because the framework may report an event without one, and an event with+    /// no start says nothing either way.+    public var startDate: Date? -    public init(type: SyncEventType, endDate: Date?, succeeded: Bool, error: NSError? = nil) {+    public init(+        type: SyncEventType, endDate: Date?, succeeded: Bool, error: NSError? = nil,+        startDate: Date? = nil+    ) {         self.type = type         self.endDate = endDate         self.succeeded = succeeded         self.error = error+        self.startDate = startDate     } } +/// How a parked `SyncMonitor.awaitExport` ended.+public enum ExportWaitResult: Equatable, Sendable {+    /// A successful export that began after the threshold.+    case exported+    /// An export or a setup that began after the threshold and failed: the+    /// mirror has said it will not carry the change now.+    case failed+    /// The optional deadline passed with nothing qualifying observed.+    case deadline+    /// The awaiting task was cancelled — the system's expiry, or a foreground+    /// open pre-empting the pass.+    case cancelled+    /// The monitor was stopped under the wait (teardown).+    case stopped+}+ /// Observes CloudKit mirroring and publishes what it saw. It never touches the /// store. ///@@ -81,6 +109,26 @@ public final class SyncMonitor {     @ObservationIgnored     private var quiescenceWaiters: [(deadline: Date, continuation: CheckedContinuation<Void, Never>)] = [] +    /// One parked `awaitExport` caller. Keyed by id so a cancellation hop, which+    /// carries nothing but the id, can find its own waiter and no other.+    private struct ExportWaiter {+        let id: UUID+        let threshold: Date+        let continuation: CheckedContinuation<ExportWaitResult, Never>+        var deadlineTask: Task<Void, Never>?+    }++    @ObservationIgnored+    private var exportWaiters: [ExportWaiter] = []++    /// Test seam: how many callers are parked in `awaitExport`. A test that+    /// feeds an event before the waiter registered would be testing nothing —+    /// and arming a wait is always one actor hop away from the call that armed+    /// it, so the ordering has to be observable. Public for the same reason+    /// `observe(_:)` is (background-export Q19): the app's test bundle imports+    /// this package without `@testable`.+    public var pendingExportWaiterCount: Int { exportWaiters.count }+     public init(         storeURL: URL,         statusURL: URL,@@ -120,7 +168,8 @@ public final class SyncMonitor {                 type: SyncEventType(event.type),                 endDate: endDate,                 succeeded: event.succeeded,-                error: event.error as NSError?)+                error: event.error as NSError?,+                startDate: event.startDate)             MainActor.assumeIsolated { self?.ingest(observed) }         })         observers.append(center.addObserver(@@ -132,6 +181,15 @@ public final class SyncMonitor {         })     } +    /// Whether `start()` has registered this monitor's observers and `stop()`+    /// has not removed them.+    ///+    /// Test seam, and public because the app's test bundle imports this package+    /// without `@testable`: a background pass must leave nothing observing when+    /// it releases the library it started a monitor for (background-export+    /// Req 1.5), and there is no other way to see that from outside.+    public var isObserving: Bool { !observers.isEmpty }+     /// Stops observing and cancels any pending debounce. Idempotent.     public func stop() {         removeObservers()@@ -141,6 +199,10 @@ public final class SyncMonitor {         // Nothing is going to arrive, so nothing is going to go quiet later:         // release the waiters now rather than leaving them on a cancelled task.         wakeQuiescenceWaiters(expiredOnly: false)+        // Same for an export wait: this monitor will observe no further events,+        // so a background pass parked on one must be told rather than left to+        // its deadline.+        for id in exportWaiters.map(\.id) { resolveExportWaiter(id, with: .stopped) }     }      /// A monitor that is released without `stop()` still leaves two registered@@ -178,7 +240,16 @@ public final class SyncMonitor {          if event.succeeded {             switch event.type {-            case .exportEvent: updated.lastExportCompleted = event.endDate+            case .exportEvent:+                updated.lastExportCompleted = event.endDate+                // The persisted half of the settlement rule (Q24). Only a+                // *successful* export carries anything, and only forward: an+                // event reported out of order that walked the stamp backwards+                // would un-settle markers an export has already taken.+                if let start = event.startDate,+                   start > (updated.lastExportStarted ?? .distantPast) {+                    updated.lastExportStarted = start+                }             case .importEvent:                 updated.lastImportCompleted = event.endDate                 // Req 6.5's only first-sync artefact. Latches: an empty library@@ -203,6 +274,17 @@ public final class SyncMonitor {         }          publish(updated)+        resolveExportWaiters(for: event)+    }++    /// The public spelling of `ingest` (background-export Q19).+    ///+    /// `NSPersistentCloudKitContainer.Event` cannot be constructed outside the+    /// framework, so a test bundle that imports this package *without*+    /// `@testable` — the app's does — has no other way to feed the monitor a+    /// completed export.+    public func observe(_ event: SyncEvent) {+        ingest(event)     }      /// Records a `.private` container construction failure as a misconfiguration@@ -301,7 +383,7 @@ public final class SyncMonitor {     /// quiet-period boundary past the deadline, so the cap is honoured within     /// one quiet period of itself.     public func awaitQuiescence(hardCap: Duration = SyncMonitor.quiescenceHardCap) async {-        let deadline = clock.now().addingTimeInterval(Self.seconds(hardCap))+        let deadline = clock.now().addingTimeInterval(hardCap.timeInterval)         while debounceTask != nil {             if clock.now() >= deadline { return }             await withCheckedContinuation { continuation in@@ -327,10 +409,89 @@ public final class SyncMonitor {         for continuation in woken { continuation.resume() }     } -    private static func seconds(_ duration: Duration) -> TimeInterval {-        let components = duration.components-        return TimeInterval(components.seconds)-            + TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000+    // MARK: - Export waits (background-export Req 1.2, 1.3)++    /// Resolves on the first *completed* event that says an export ran after+    /// `threshold`: `.exported` for a successful export, `.failed` for a failed+    /// export or a failed setup — the mirror announcing it will not export.+    ///+    /// The comparison is on the event's **start** and is strict (Q8, Q24): an+    /// export that started at or before the marker's creation may have been+    /// enqueued before the commit, so it proves nothing. Imports, successful+    /// setups, and events the framework reported without a start date are+    /// ignored.+    ///+    /// It also resolves `.deadline` when the optional deadline passes,+    /// `.cancelled` when the awaiting task is cancelled — the grant expiring, or+    /// a foreground open pre-empting the pass — and `.stopped` when the monitor+    /// is torn down under it.+    ///+    /// `awaitQuiescence` is deliberately not the template here: it handles no+    /// cancellation at all, which for a background pass would mean running past+    /// the grant (Q30).+    public func awaitExport(startedAfter threshold: Date, deadline: Date?) async -> ExportWaitResult {+        let id = UUID()+        return await withTaskCancellationHandler {+            await withCheckedContinuation { (continuation: CheckedContinuation<ExportWaitResult, Never>) in+                // Checked *inside* the registration, on the same actor hop that+                // appends: a task already cancelled when the call is made would+                // otherwise register a waiter nothing will ever resume, because+                // `onCancel` has already run.+                guard !Task.isCancelled else {+                    continuation.resume(returning: .cancelled)+                    return+                }+                // The sleeper is built first and the waiter registered with it+                // in one step, so there is no window in which a waiter exists+                // without the task that has to be cancelled when it resolves.+                // The task's body cannot run before that: it starts on the next+                // hop of this actor, and everything here is synchronous.+                var deadlineTask: Task<Void, Never>?+                if let deadline {+                    let remaining = max(0, deadline.timeIntervalSince(clock.now()))+                    deadlineTask = Task { @MainActor [weak self] in+                        guard let self else { return }+                        await self.sleeper(.seconds(remaining))+                        guard !Task.isCancelled else { return }+                        self.resolveExportWaiter(id, with: .deadline)+                    }+                }+                exportWaiters.append(ExportWaiter(+                    id: id, threshold: threshold, continuation: continuation,+                    deadlineTask: deadlineTask))+            }+        } onCancel: {+            // Non-isolated, and does nothing but hop: the waiter list is+            // main-actor state and every resolver has to reach it there.+            Task { @MainActor [weak self] in self?.resolveExportWaiter(id, with: .cancelled) }+        }+    }++    /// Answers every waiter this event qualifies for.+    private func resolveExportWaiters(for event: SyncEvent) {+        guard let start = event.startDate else { return }+        let result: ExportWaitResult+        switch (event.type, event.succeeded) {+        case (.exportEvent, true): result = .exported+        case (.exportEvent, false), (.setup, false): result = .failed+        // A successful setup is not an export, and an import is the other+        // direction entirely.+        case (.setup, true), (.importEvent, _): return+        }+        for id in exportWaiters.filter({ start > $0.threshold }).map(\.id) {+            resolveExportWaiter(id, with: result)+        }+    }++    /// The one resolver. **Removes the waiter, then resumes it**, so whichever+    /// of the event, the deadline sleeper, `stop()` and the cancellation hop+    /// gets here first is the only one that resumes the continuation — resuming+    /// one twice traps.+    private func resolveExportWaiter(_ id: UUID, with result: ExportWaitResult) {+        guard let index = exportWaiters.firstIndex(where: { $0.id == id }) else { return }+        let waiter = exportWaiters.remove(at: index)+        waiter.deadlineTask?.cancel()+        waiter.continuation.resume(returning: result)     } } 
Packages/AsterismCore/Sources/AsterismCore/SyncStatus.swift Modified +14 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SyncStatus.swift b/Packages/AsterismCore/Sources/AsterismCore/SyncStatus.swiftindex 40077f5..6f9a038 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SyncStatus.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SyncStatus.swift@@ -79,6 +79,18 @@ public struct SyncStatusRecord: Codable, Sendable, Equatable {      public var version: Int     public var lastExportCompleted: Date?+    /// When the latest *successful* export **began** (background-export Q24).+    ///+    /// The persisted half of the export-owed settlement rule: a marker older+    /// than this has already been carried by an export, so it can be cleared at+    /// listing time with no library open and no wait. It is the export's start+    /// and never its end, because an export that started before a commit can+    /// end after it.+    ///+    /// Additive: a record written before this field decodes with it nil, and one+    /// written after it is ignored by an older build, so `currentVersion` stays+    /// where it is.+    public var lastExportStarted: Date?     public var lastImportCompleted: Date?     /// Req 6.5: distinguishes a library that is still arriving from one that is     /// genuinely empty. Latches true on the first completed import and never@@ -89,12 +101,14 @@ public struct SyncStatusRecord: Codable, Sendable, Equatable {     public init(         version: Int = SyncStatusRecord.currentVersion,         lastExportCompleted: Date? = nil,+        lastExportStarted: Date? = nil,         lastImportCompleted: Date? = nil,         hasEverImported: Bool = false,         lastFailure: SyncFailureRecord? = nil     ) {         self.version = version         self.lastExportCompleted = lastExportCompleted+        self.lastExportStarted = lastExportStarted         self.lastImportCompleted = lastImportCompleted         self.hasEverImported = hasEverImported         self.lastFailure = lastFailure
Packages/AsterismCore/Tests/AsterismCoreTests/BackgroundExportPassTests.swift Added +454 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackgroundExportPassTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackgroundExportPassTests.swiftnew file mode 100644index 0000000..dd02bce--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackgroundExportPassTests.swift@@ -0,0 +1,454 @@+import CloudKit+import Foundation+import Testing+@testable import AsterismCore++/// Task 4: the pass — settle, acquire, wait, clear, release — and the outcomes+/// it reports (Req 1.2–1.5, 1.9, 1.10, 2.2, 2.3, 4.1).+///+/// Nothing here opens a store or a container. The library session is a fake that+/// records `acquire`/`release` and hands back a real `SyncMonitor` over a+/// temporary status file, which the test drives through `observe`, so every+/// decision the pass makes is exercised on the host.+@Suite("Background export pass")+@MainActor+struct BackgroundExportPassTests {++    // MARK: - Fixtures++    private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    private static func tempRoot() -> URL {+        FileManager.default.temporaryDirectory.appending(path: "BackgroundExport-\(UUID())")+    }++    /// A marker area, a status file, and a monitor over them.+    private struct Fixture {+        let root: URL+        let marker: ExportOwedMarker+        let statusURL: URL+        let monitor: SyncMonitor++        func remove() { try? FileManager.default.removeItem(at: root) }+    }++    /// The monitor's deadline sleeper. `nil` never returns until the deadline+    /// task is cancelled, so an event or a cancellation is what ends the wait;+    /// the immediate one is how a test reaches `.timedOut` with no real wait.+    private enum Sleeping {+        static let long: @Sendable (Duration) async -> Void = { _ in+            try? await Task.sleep(for: .seconds(600))+        }+        static let immediate: @Sendable (Duration) async -> Void = { _ in }+    }++    private func fixture(+        sleeper: @escaping @Sendable (Duration) async -> Void = Sleeping.long+    ) throws -> Fixture {+        let root = Self.tempRoot()+        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)+        let configuration = LibraryConfiguration(rootDirectory: root)+        return Fixture(+            root: root,+            marker: ExportOwedMarker(directory: configuration.exportOwedURL),+            statusURL: configuration.syncStatusURL,+            monitor: SyncMonitor(+                storeURL: configuration.storeURL,+                statusURL: configuration.syncStatusURL,+                clock: FixedRepositoryClock(Self.epoch),+                sleeper: sleeper))+    }++    /// Marks, then stamps the new file with an explicit creation date: the+    /// settlement rule is entirely about those dates, and two marks written in+    /// one file-system timestamp would prove nothing about ordering.+    @discardableResult+    private func mark(_ fixture: Fixture, createdAt: Date) throws -> String {+        let before = Set(try fixture.marker.pending().map(\.name))+        try fixture.marker.mark()+        let name = try #require(+            try fixture.marker.pending().map(\.name).first { !before.contains($0) })+        try FileManager.default.setAttributes(+            [.creationDate: createdAt],+            ofItemAtPath: fixture.marker.directory.appending(path: name).path)+        return name+    }++    private func writeStatus(_ record: SyncStatusRecord, to url: URL) {+        #expect(SyncStatusFile.write(record, to: url))+    }++    private func pass(+        _ fixture: Fixture,+        clock: any RepositoryClock = FixedRepositoryClock(BackgroundExportPassTests.epoch),+        log: @escaping @Sendable (BackgroundExportLogLine) -> Void = { _ in }+    ) -> BackgroundExportPass {+        BackgroundExportPass(+            marker: fixture.marker, statusURL: fixture.statusURL, clock: clock, log: log)+    }++    private func exportEvent(+        succeeded: Bool = true, startedAt: Date, error: NSError? = nil+    ) -> SyncEvent {+        SyncEvent(+            type: .exportEvent, endDate: startedAt.addingTimeInterval(1),+            succeeded: succeeded, error: error, startDate: startedAt)+    }++    /// Waits for the pass to have parked in `awaitExport`. Bounded, so a pass+    /// that never gets there fails rather than hanging the suite.+    private func awaitWaiting(_ fixture: Fixture) async {+        for _ in 0..<1_000 where fixture.monitor.pendingExportWaiterCount == 0 {+            await Task.yield()+        }+        #expect(fixture.monitor.pendingExportWaiterCount == 1)+    }++    // MARK: - Nothing owed (Req 2.2, Q25)++    @Test("No marker ends the pass without touching the library")+    func noMarkerSkipsWithoutAcquiring() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        let session = FakeSession()++        let outcome = await pass(fixture).run(session: session)++        #expect(outcome == .skipped(.noMarker))+        #expect(session.acquireCount == 0)+        #expect(session.releaseCount == 0)+    }++    /// The persisted half of the rule doing its job: an export ran while nobody+    /// was watching, so the markers are settled at listing time with no library+    /// open and no wait (Q24).+    @Test("Markers older than the recorded export start are settled without acquiring")+    func alreadyExportedMarkersAreSettled() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch.addingTimeInterval(-100))+        try mark(fixture, createdAt: Self.epoch.addingTimeInterval(-50))+        writeStatus(SyncStatusRecord(lastExportStarted: Self.epoch), to: fixture.statusURL)+        let session = FakeSession()++        let outcome = await pass(fixture).run(session: session)++        #expect(outcome == .skipped(.alreadyExported))+        #expect(try fixture.marker.pending().isEmpty)+        #expect(session.acquireCount == 0)+    }++    /// Req 2.6: a container that cannot be listed is a diagnosis, not "nothing+    /// owed" — the marker survives and the next grant tries again.+    @Test("An unreadable marker directory is reported as unavailable")+    func unreadableMarkerDirectoryIsUnavailable() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try Data("not a directory".utf8).write(to: fixture.marker.directory)+        let session = FakeSession()++        let outcome = await pass(fixture).run(session: session)++        guard case .skipped(.unavailable(let reason)) = outcome else {+            Issue.record("expected an unavailable skip, got \(outcome)")+            return+        }+        #expect(!reason.isEmpty)+        #expect(session.acquireCount == 0)+    }++    // MARK: - The threshold (Req 1.2)++    @Test("A mix settles the old markers and waits on the newest outstanding one")+    func mixedMarkersWaitOnTheNewestOutstanding() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        let settledName = try mark(fixture, createdAt: Self.epoch.addingTimeInterval(-100))+        let older = try mark(fixture, createdAt: Self.epoch.addingTimeInterval(10))+        let newest = try mark(fixture, createdAt: Self.epoch.addingTimeInterval(20))+        writeStatus(SyncStatusRecord(lastExportStarted: Self.epoch), to: fixture.statusURL)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))++        let task = Task { @MainActor in await pass(fixture).run(session: session) }+        await awaitWaiting(fixture)++        #expect(!(try fixture.marker.pending().map(\.name).contains(settledName)))++        // Later than the older marker but not than the newest: the threshold is+        // the newest outstanding creation date, so this settles nothing.+        fixture.monitor.observe(exportEvent(startedAt: Self.epoch.addingTimeInterval(15)))+        #expect(fixture.monitor.pendingExportWaiterCount == 1)++        fixture.monitor.observe(exportEvent(startedAt: Self.epoch.addingTimeInterval(25)))+        #expect(await task.value == .exported)+        #expect(try fixture.marker.pending().isEmpty)+        #expect(Set([older, newest]).count == 2)+        #expect(session.releaseCount == 1)+    }++    /// Req 2.3, the property compare-and-clear exists for: a capture committed+    /// while the pass was waiting is not in the listed set and survives it.+    @Test("An export clears the listed markers and not one marked after the listing")+    func exportClearsOnlyWhatThePassFound() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))++        let task = Task { @MainActor in await pass(fixture).run(session: session) }+        await awaitWaiting(fixture)++        let afterListing = try mark(fixture, createdAt: Self.epoch.addingTimeInterval(5))+        fixture.monitor.observe(exportEvent(startedAt: Self.epoch.addingTimeInterval(30)))++        #expect(await task.value == .exported)+        #expect(try fixture.marker.pending().map(\.name) == [afterListing])+        #expect(session.releaseCount == 1)+    }++    // MARK: - What acquire() answers (Req 1.9, 1.10)++    @Test("Every skip reason from acquire passes through, and none of them releases")+    func skipReasonsPassThroughWithoutRelease() async throws {+        let reasons: [BackgroundExportOutcome.SkipReason] = [+            .unavailable("a migration is pending"), .bulkOperation, .noMarker, .alreadyExported,+        ]+        for reason in reasons {+            let fixture = try fixture()+            defer { fixture.remove() }+            try mark(fixture, createdAt: Self.epoch)+            let session = FakeSession(acquisition: .skipped(reason))++            let outcome = await pass(fixture).run(session: session)++            #expect(outcome == .skipped(reason))+            #expect(session.acquireCount == 1)+            #expect(session.releaseCount == 0, "nothing was acquired, so there is nothing to release")+            #expect(try fixture.marker.pending().count == 1, "the marker survives a skip")+        }+    }++    /// Q30: an expiry and a foreground pre-emption are the same cancellation and+    /// must be distinguishable in the log.+    @Test("A cancelled acquire is expiry or pre-emption, and releases nothing")+    func cancelledAcquireIsExpiryOrPreemption() async throws {+        for preempting in [false, true] {+            let fixture = try fixture()+            defer { fixture.remove() }+            try mark(fixture, createdAt: Self.epoch)+            let session = FakeSession(acquisition: .cancelled)+            session.isPreempting = preempting++            let outcome = await pass(fixture).run(session: session)++            #expect(outcome == (preempting ? .preempted : .expired))+            #expect(session.releaseCount == 0)+            #expect(try fixture.marker.pending().count == 1)+        }+    }++    // MARK: - What the wait answers (Req 1.3, 1.4, 1.5)++    @Test("A deadline with nothing observed times out, releases, and leaves the marker")+    func deadlineTimesOut() async throws {+        let fixture = try fixture(sleeper: Sleeping.immediate)+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))++        let outcome = await pass(fixture).run(session: session)++        #expect(outcome == .timedOut)+        #expect(session.releaseCount == 1)+        #expect(try fixture.marker.pending().count == 1)+    }++    @Test("A failed export ends the pass early, releases, and leaves the marker")+    func failedExportEndsThePass() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))++        let task = Task { @MainActor in await pass(fixture).run(session: session) }+        await awaitWaiting(fixture)+        fixture.monitor.observe(exportEvent(+            succeeded: false, startedAt: Self.epoch.addingTimeInterval(30),+            error: NSError(domain: CKError.errorDomain, code: CKError.Code.networkUnavailable.rawValue)))++        guard case .failed(let message) = await task.value else {+            Issue.record("expected a failure outcome")+            return+        }+        #expect(!message.isEmpty)+        #expect(session.releaseCount == 1)+        #expect(try fixture.marker.pending().count == 1)+    }++    /// Req 1.5: whatever ends the pass, the library is released before it+    /// reports — a process suspended afterwards must hold nothing open.+    @Test("Cancellation after acquire releases without waiting")+    func cancellationAfterAcquireReleasesWithoutWaiting() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))+        let box = TaskBox()+        session.onAcquire = { [box] in box.task?.cancel() }++        let task = Task { @MainActor in await pass(fixture).run(session: session) }+        box.task = task++        #expect(await task.value == .expired)+        #expect(session.releaseCount == 1)+        #expect(fixture.monitor.pendingExportWaiterCount == 0, "the pass never parked")+        #expect(try fixture.marker.pending().count == 1)+    }++    @Test("A monitor stopped under the wait is a pre-emption, and still releases")+    func stoppedMonitorIsPreemption() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))++        let task = Task { @MainActor in await pass(fixture).run(session: session) }+        await awaitWaiting(fixture)+        fixture.monitor.stop()++        #expect(await task.value == .preempted)+        #expect(session.releaseCount == 1)+        #expect(try fixture.marker.pending().count == 1)+    }++    // MARK: - Logging (Req 4.1)++    @Test("The start and end lines share one pass identifier and the end carries the outcome")+    func logLinesShareAPassIdentifier() async throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch.addingTimeInterval(-100))+        try mark(fixture, createdAt: Self.epoch.addingTimeInterval(10))+        writeStatus(SyncStatusRecord(lastExportStarted: Self.epoch), to: fixture.statusURL)+        let sink = LogSink()+        let clock = AdvancingClock(Self.epoch)+        let session = FakeSession(acquisition: .monitor(fixture.monitor))+        session.onAcquire = { clock.advance(by: 5) }++        let task = Task { @MainActor in+            await pass(fixture, clock: clock, log: sink.record).run(session: session)+        }+        await awaitWaiting(fixture)+        fixture.monitor.observe(exportEvent(startedAt: Self.epoch.addingTimeInterval(30)))+        #expect(await task.value == .exported)++        let lines = sink.lines+        #expect(lines.count == 2)+        #expect(lines.first?.phase == .start)+        #expect(lines.first?.listed == 2)+        #expect(lines.first?.settled == 1)+        #expect(lines.last?.phase == .end)+        #expect(lines.last?.outcome == .exported)+        #expect(lines.last?.elapsed == .seconds(5))+        #expect(lines.first?.passID == lines.last?.passID)+    }++    @Test("Every outcome renders as a short unwrapped phrase for the log")+    func outcomesRenderAsReadableText() {+        #expect(BackgroundExportOutcome.exported.logDescription == "exported")+        #expect(BackgroundExportOutcome.skipped(.noMarker).logDescription == "skipped (no marker)")+        #expect(+            BackgroundExportOutcome.skipped(.unavailable("locked")).logDescription+                == "skipped (library unavailable: locked)")+        #expect(BackgroundExportOutcome.timedOut.logDescription == "timed out")+        #expect(BackgroundExportOutcome.failed("quota").logDescription == "failed: quota")+    }++    // MARK: - settle (the persisted half, shared with the foreground arm)++    @Test("settle returns the outstanding entries and clears the rest")+    func settleClearsWhatAnExportAlreadyCarried() throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        let old = try mark(fixture, createdAt: Self.epoch.addingTimeInterval(-10))+        let new = try mark(fixture, createdAt: Self.epoch.addingTimeInterval(10))+        let entries = try fixture.marker.pending()++        let outstanding = BackgroundExportPass.settle(+            entries, against: SyncStatusRecord(lastExportStarted: Self.epoch),+            marker: fixture.marker)++        #expect(outstanding.map(\.name) == [new])+        #expect(try fixture.marker.pending().map(\.name) == [new])+        #expect(old != new)+    }++    @Test("settle with nothing recorded leaves every marker outstanding")+    func settleWithoutARecordedStartKeepsEverything() throws {+        let fixture = try fixture()+        defer { fixture.remove() }+        try mark(fixture, createdAt: Self.epoch)+        let entries = try fixture.marker.pending()++        let outstanding = BackgroundExportPass.settle(+            entries, against: .neverSynced, marker: fixture.marker)++        #expect(outstanding.count == 1)+        #expect(try fixture.marker.pending().count == 1)+    }+}++// MARK: - Test doubles++/// The library session under test control: what `acquire()` answers, how many+/// times each side was called, and a hook that runs inside `acquire()` so a test+/// can cancel the pass exactly between acquisition and the wait.+@MainActor+private final class FakeSession: BackgroundExportSession {+    var acquisition: BackgroundExportAcquisition+    var isPreempting = false+    private(set) var acquireCount = 0+    private(set) var releaseCount = 0+    var onAcquire: (@MainActor () -> Void)?++    init(acquisition: BackgroundExportAcquisition = .cancelled) {+        self.acquisition = acquisition+    }++    func acquire() async -> BackgroundExportAcquisition {+        acquireCount += 1+        onAcquire?()+        return acquisition+    }++    func release() async {+        releaseCount += 1+    }+}++/// Holds the task a fake's `onAcquire` cancels, which cannot capture the task it+/// is installed before.+@MainActor+private final class TaskBox {+    var task: Task<BackgroundExportOutcome, Never>?+}++private final class LogSink: @unchecked Sendable {+    private(set) var lines: [BackgroundExportLogLine] = []++    var record: @Sendable (BackgroundExportLogLine) -> Void {+        { [self] line in lines.append(line) }+    }+}++/// A clock the test moves by hand, so the elapsed time in the end line is a+/// value rather than a measurement.+private final class AdvancingClock: RepositoryClock, @unchecked Sendable {+    private var instant: Date++    init(_ instant: Date) { self.instant = instant }++    func now() -> Date { instant }++    func advance(by seconds: TimeInterval) { instant = instant.addingTimeInterval(seconds) }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ExportOwedMarkerTests.swift Added +127 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ExportOwedMarkerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ExportOwedMarkerTests.swiftnew file mode 100644index 0000000..ce656db--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ExportOwedMarkerTests.swift@@ -0,0 +1,127 @@+import Foundation+import Testing+@testable import AsterismCore++/// Task 1: the App Group marker directory the extension writes into after every+/// committed capture and the app clears after an export it observed+/// (Req 2.1, 2.3, 2.5, 2.6; Decision 3).+///+/// Nothing here opens a store. The marker is a directory of empty UUID-named+/// files, so every property it has is reachable from the file system.+///+/// The protection class is deliberately *not* asserted: `FileProtectionType` is+/// inert on macOS, so a host assertion would pass without proving anything. The+/// runbook checks it on a device.+@Suite("Export-owed marker")+struct ExportOwedMarkerTests {++    // MARK: - Fixtures++    /// A directory path that does not exist yet — `mark()` is what creates it.+    private static func tempMarkerDirectory() -> URL {+        FileManager.default.temporaryDirectory.appending(path: "ExportOwed-\(UUID())")+    }++    // MARK: - Listing++    /// A missing directory is "nothing owed", not an error: the app can run+    /// before the extension has ever captured.+    @Test("A missing directory reads as empty")+    func missingDirectoryIsEmpty() throws {+        let marker = ExportOwedMarker(directory: Self.tempMarkerDirectory())+        #expect(try marker.pending().isEmpty)+    }++    @Test("Marking twice yields two distinct entries in creation order")+    func markingTwiceYieldsTwoEntries() throws {+        let directory = Self.tempMarkerDirectory()+        defer { try? FileManager.default.removeItem(at: directory) }+        let marker = ExportOwedMarker(directory: directory)++        try marker.mark()+        try marker.mark()++        let entries = try marker.pending()+        #expect(entries.count == 2)+        #expect(Set(entries.map(\.name)).count == 2, "each commit leaves a distinguishable marker (Req 2.1)")+        #expect(entries[0].createdAt <= entries[1].createdAt)+    }++    /// Req 2.6: an unreadable directory is a *diagnosis*, not an empty answer. A+    /// `pending()` that swallowed the error would report "no marker" for a+    /// protected container and the capture would wait for a foreground open.+    @Test("A regular file where the directory should be makes pending() throw")+    func unreadableDirectoryThrows() throws {+        let directory = Self.tempMarkerDirectory()+        defer { try? FileManager.default.removeItem(at: directory) }+        try Data("not a directory".utf8).write(to: directory)++        let marker = ExportOwedMarker(directory: directory)+        #expect(throws: (any Error).self) { try marker.pending() }+    }++    // MARK: - Clearing (Req 2.3, Decision 3)++    /// The property compare-and-clear exists for: a marker created after the+    /// listing is not in the cleared set and survives whatever the pass does.+    @Test("clear removes exactly the named entries and leaves a later one")+    func clearRemovesOnlyTheNamedEntries() throws {+        let directory = Self.tempMarkerDirectory()+        defer { try? FileManager.default.removeItem(at: directory) }+        let marker = ExportOwedMarker(directory: directory)++        try marker.mark()+        try marker.mark()+        let listed = try marker.pending()+        #expect(listed.count == 2)++        // A capture committed after the listing, which the pass never saw.+        try marker.mark()++        marker.clear(listed.map(\.name))++        let remaining = try marker.pending()+        #expect(remaining.count == 1)+        #expect(!listed.map(\.name).contains(remaining[0].name))+    }++    /// Clearing what is already gone is the normal case after two passes race,+    /// so ENOENT is not an error to report (Q18).+    @Test("Clearing a name that is not there is silent")+    func clearingAnAbsentNameIsSilent() throws {+        let directory = Self.tempMarkerDirectory()+        defer { try? FileManager.default.removeItem(at: directory) }+        let marker = ExportOwedMarker(directory: directory)++        try marker.mark()+        let listed = try marker.pending()+        marker.clear(listed.map(\.name))+        marker.clear(listed.map(\.name))+        marker.clear(["never-existed"])++        #expect(try marker.pending().isEmpty)+    }++    // MARK: - Writing (Req 2.5's failing arm)++    /// The extension ignores this and completes the capture anyway; that the+    /// write *reports* its failure is what makes the ignoring a decision.+    @Test("mark() into an unwritable location throws")+    func markIntoAnUnwritableLocationThrows() throws {+        let file = FileManager.default.temporaryDirectory.appending(path: "ExportOwedBlocker-\(UUID())")+        defer { try? FileManager.default.removeItem(at: file) }+        try Data("a file, not a directory".utf8).write(to: file)++        let marker = ExportOwedMarker(directory: file.appending(path: "ExportOwed"))+        #expect(throws: (any Error).self) { try marker.mark() }+    }++    // MARK: - Configuration++    @Test("The marker directory sits beside the pending-capture area")+    func configurationDeclaresTheDirectory() {+        let root = URL(filePath: "/tmp/asterism-marker-fixture")+        let configuration = LibraryConfiguration(rootDirectory: root)+        #expect(configuration.exportOwedURL == root.appending(path: "ExportOwed"))+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/SyncMonitorTests.swift Modified +259 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SyncMonitorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SyncMonitorTests.swiftindex 6a0fb29..1dccfe0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SyncMonitorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SyncMonitorTests.swift@@ -51,9 +51,21 @@ struct SyncMonitorTests {     }      private func completed(-        _ type: SyncEventType, succeeded: Bool = true, error: NSError? = nil, at date: Date = SyncMonitorTests.fixedNow+        _ type: SyncEventType, succeeded: Bool = true, error: NSError? = nil,+        at date: Date = SyncMonitorTests.fixedNow, startedAt: Date? = nil     ) -> SyncEvent {-        SyncEvent(type: type, endDate: date, succeeded: succeeded, error: error)+        SyncEvent(type: type, endDate: date, succeeded: succeeded, error: error, startDate: startedAt)+    }++    /// Waits for a parked `awaitExport` to have registered before the test feeds+    /// it anything. Bounded, so a monitor that never registers fails the test+    /// rather than hanging the suite.+    @MainActor+    private func awaitRegistration(_ monitor: SyncMonitor, count: Int = 1) async {+        for _ in 0..<1_000 where monitor.pendingExportWaiterCount < count {+            await Task.yield()+        }+        #expect(monitor.pendingExportWaiterCount == count)     }      // MARK: - Classification (the design's Error Handling table)@@ -416,6 +428,251 @@ struct SyncMonitorTests {         #expect(await calls.count == 1)     } +    // MARK: - The persisted half of the settlement rule (background-export Q24)++    @MainActor+    @Test("Only a successful export records lastExportStarted, and only forward")+    func exportStartIsRecordedOnlyForwardAndOnlyOnSuccess() {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)+        let start = Self.fixedNow.addingTimeInterval(-10)++        #expect(monitor.status.lastExportStarted == nil)++        // An import says nothing about export.+        monitor.observe(completed(.importEvent, startedAt: start))+        #expect(monitor.status.lastExportStarted == nil)++        // Nor does a failed export: it exported nothing.+        monitor.observe(completed(+            .exportEvent, succeeded: false, error: Self.ckError(.networkUnavailable), startedAt: start))+        #expect(monitor.status.lastExportStarted == nil)++        monitor.observe(completed(.exportEvent, startedAt: start))+        #expect(monitor.status.lastExportStarted == start)++        // An event reported out of order must not walk the stamp backwards: a+        // marker older than the recorded start is settled, and a stamp that+        // moved back would un-settle markers an export has already carried.+        monitor.observe(completed(.exportEvent, startedAt: start.addingTimeInterval(-60)))+        #expect(monitor.status.lastExportStarted == start)++        let later = start.addingTimeInterval(60)+        monitor.observe(completed(.exportEvent, startedAt: later))+        #expect(monitor.status.lastExportStarted == later)++        // An export with no start date carries no evidence about when it began.+        monitor.observe(completed(.exportEvent, startedAt: nil))+        #expect(monitor.status.lastExportStarted == later)+    }++    @Test("A status file written before the field decodes with it absent")+    func olderStatusFilesDecodeWithoutTheExportStart() throws {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let legacy = """+        {"version":1,"hasEverImported":true,"lastExportCompleted":\+        \(Self.fixedNow.timeIntervalSinceReferenceDate)}+        """+        try Data(legacy.utf8).write(to: url)++        let record = SyncStatusFile.read(from: url)+        #expect(record.hasEverImported)+        #expect(record.lastExportStarted == nil)+        #expect(SyncStatusRecord.currentVersion == 1, "the new field is additive; the format is unchanged")+    }++    // MARK: - The live half: awaitExport (background-export Req 1.2, 1.3)++    @MainActor+    @Test("An export that started at or before the threshold does not resolve the wait")+    func exportsAtOrBeforeTheThresholdAreIgnored() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)+        let threshold = Self.fixedNow++        let waiter = Task { @MainActor in+            await monitor.awaitExport(startedAfter: threshold, deadline: nil)+        }+        await awaitRegistration(monitor)++        // An export already finished when the marker was written.+        monitor.observe(completed(.exportEvent, startedAt: threshold.addingTimeInterval(-1)))+        // The boundary is strict: "later than", not "no earlier than".+        monitor.observe(completed(.exportEvent, startedAt: threshold))+        // An event whose start the framework did not report proves nothing.+        monitor.observe(completed(.exportEvent, startedAt: nil))+        // An import is the other direction entirely.+        monitor.observe(completed(.importEvent, startedAt: threshold.addingTimeInterval(60)))+        // A *successful* setup is not an export either.+        monitor.observe(completed(.setup, startedAt: threshold.addingTimeInterval(60)))+        #expect(monitor.pendingExportWaiterCount == 1, "none of those said an export ran")++        monitor.observe(completed(.exportEvent, startedAt: threshold.addingTimeInterval(60)))+        #expect(await waiter.value == .exported)+        #expect(monitor.pendingExportWaiterCount == 0)+    }++    @MainActor+    @Test("A failed export after the threshold ends the wait")+    func failedExportResolvesAsFailed() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)++        let waiter = Task { @MainActor in+            await monitor.awaitExport(startedAfter: Self.fixedNow, deadline: nil)+        }+        await awaitRegistration(monitor)++        monitor.observe(completed(+            .exportEvent, succeeded: false, error: Self.ckError(.networkUnavailable),+            startedAt: Self.fixedNow.addingTimeInterval(30)))+        #expect(await waiter.value == .failed)+    }++    /// Q27: the mirror announcing it cannot set up is the mirror announcing it+    /// will not export. Holding the library for the rest of the budget after+    /// that wastes the grant.+    @MainActor+    @Test("A failed setup after the threshold ends the wait")+    func failedSetupResolvesAsFailed() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)++        let waiter = Task { @MainActor in+            await monitor.awaitExport(startedAfter: Self.fixedNow, deadline: nil)+        }+        await awaitRegistration(monitor)++        monitor.observe(completed(+            .setup, succeeded: false, error: Self.cocoaError(134_400),+            startedAt: Self.fixedNow.addingTimeInterval(1)))+        #expect(await waiter.value == .failed)+    }++    /// The deadline is what ends a pass whose mirror has nothing to say. The+    /// fake sleeper makes that instant rather than a real 20 s wait.+    @MainActor+    @Test("The deadline resolves the wait through the injected sleeper")+    func deadlineResolvesThroughTheSleeper() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let sleeper = RecordingSleeper()+        let monitor = monitor(statusURL: url, sleeper: sleeper.sleep)++        let result = await monitor.awaitExport(+            startedAfter: Self.fixedNow, deadline: Self.fixedNow.addingTimeInterval(20))++        #expect(result == .deadline)+        #expect(sleeper.requested == [.seconds(20)], "the wait is what remains of the budget")+        #expect(monitor.pendingExportWaiterCount == 0)+    }++    @MainActor+    @Test("Cancelling the awaiting task resolves the wait")+    func cancellationResolvesTheWait() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)++        let waiter = Task { @MainActor in+            await monitor.awaitExport(startedAfter: Self.fixedNow, deadline: nil)+        }+        await awaitRegistration(monitor)++        waiter.cancel()+        #expect(await waiter.value == .cancelled)+        #expect(monitor.pendingExportWaiterCount == 0)+    }++    /// The expiry can arrive before the pass reaches its wait, and a waiter that+    /// registered on an already-cancelled task would never be resumed.+    @MainActor+    @Test("A task cancelled before the call never registers")+    func alreadyCancelledTaskResolvesImmediately() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)++        let waiter = Task { @MainActor in+            while !Task.isCancelled { await Task.yield() }+            return await monitor.awaitExport(startedAfter: Self.fixedNow, deadline: nil)+        }+        waiter.cancel()++        #expect(await waiter.value == .cancelled)+        #expect(monitor.pendingExportWaiterCount == 0)+    }++    @MainActor+    @Test("stop() releases the export waiters")+    func stopResolvesTheWait() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)++        let waiter = Task { @MainActor in+            await monitor.awaitExport(startedAfter: Self.fixedNow, deadline: nil)+        }+        await awaitRegistration(monitor)++        monitor.stop()+        #expect(await waiter.value == .stopped)+        #expect(monitor.pendingExportWaiterCount == 0)+    }++    @MainActor+    @Test("Two waiters with different thresholds resolve independently")+    func twoWaitersResolveIndependently() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)+        let early = Self.fixedNow+        let late = Self.fixedNow.addingTimeInterval(100)++        let first = Task { @MainActor in await monitor.awaitExport(startedAfter: early, deadline: nil) }+        await awaitRegistration(monitor, count: 1)+        let second = Task { @MainActor in await monitor.awaitExport(startedAfter: late, deadline: nil) }+        await awaitRegistration(monitor, count: 2)++        monitor.observe(completed(.exportEvent, startedAt: early.addingTimeInterval(10)))+        #expect(await first.value == .exported)+        #expect(monitor.pendingExportWaiterCount == 1, "the later threshold is not answered by that export")++        monitor.observe(completed(.exportEvent, startedAt: late.addingTimeInterval(10)))+        #expect(await second.value == .exported)+    }++    /// A continuation resumed twice traps. The event and the cancellation hop+    /// race for the same waiter, and whichever wins must remove it first.+    @MainActor+    @Test("A waiter resolves exactly once when an event and a cancellation race")+    func aWaiterResolvesExactlyOnce() async {+        let url = Self.tempStatusURL()+        defer { try? FileManager.default.removeItem(at: url) }+        let monitor = monitor(statusURL: url)++        let waiter = Task { @MainActor in+            await monitor.awaitExport(startedAfter: Self.fixedNow, deadline: nil)+        }+        await awaitRegistration(monitor)++        // `cancel()` only schedules a main-actor hop, so the event that follows+        // it in this same turn is the resolver that wins.+        waiter.cancel()+        monitor.observe(completed(.exportEvent, startedAt: Self.fixedNow.addingTimeInterval(5)))++        #expect(await waiter.value == .exported)+        #expect(monitor.pendingExportWaiterCount == 0)+        // Let the cancellation hop run: it must find nothing to resume.+        await Task.yield()+        #expect(monitor.pendingExportWaiterCount == 0)+    }+     @MainActor     @Test("start() and stop() are idempotent")     func lifecycleIsIdempotent() {
scripts/verify-identity.sh Modified +94 / -11
diff --git a/scripts/verify-identity.sh b/scripts/verify-identity.shindex 3ed4371..c58a4e5 100755--- a/scripts/verify-identity.sh+++ b/scripts/verify-identity.sh@@ -54,10 +54,15 @@ readonly EXPECTED_TOKEN_PERSONAL="me.nore.ig.Asterism" # The exact derivation text expected at project level, verbatim. readonly EXPECTED_GROUP_DERIVATION='group.$(ASTERISM_IDENTITY)' readonly EXPECTED_CONTAINER_DERIVATION='iCloud.$(ASTERISM_IDENTITY)'+# The background-export task identifier (background-export Q22). It derives from+# the same one token for the same reason: a Development build must never submit,+# or be granted, the Personal build's task.+readonly EXPECTED_BACKGROUND_TASK_DERIVATION='$(ASTERISM_IDENTITY).backgroundExport'  readonly APP_GROUP_SETTING="ASTERISM_APP_GROUP_IDENTIFIER" readonly CONTAINER_SETTING="ASTERISM_ICLOUD_CONTAINER_IDENTIFIER" readonly IDENTITY_SETTING="ASTERISM_IDENTITY"+readonly BACKGROUND_TASK_SETTING="ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER" # The per-configuration mirroring gate (cloudkit-mirroring Q51). Unlike the two # above it derives from nothing: it is a flip, and each flip is one pbxproj # value. It is linted on the same terms all the same, because it decides whether@@ -72,6 +77,14 @@ readonly MIRRORING_SETTING="ASTERISM_MIRRORING_ENABLED" readonly APP_GROUP_PLIST_KEY="AsterismAppGroupIdentifier" readonly CONTAINER_PLIST_KEY="AsterismCloudKitContainerIdentifier" readonly MIRRORING_PLIST_KEY="AsterismCloudKitMirroringEnabled"+# The background-export task, three plist keys deep (background-export Req 3.4):+# the identifier the scheduler reads, the system's permission list that must+# name the same identifier, and the background mode without which no refresh+# grant ever arrives. All three are the app's alone.+readonly BACKGROUND_TASK_PLIST_KEY="AsterismBackgroundExportTaskIdentifier"+readonly PERMITTED_IDENTIFIERS_PLIST_KEY="BGTaskSchedulerPermittedIdentifiers"+readonly BACKGROUND_MODES_PLIST_KEY="UIBackgroundModes"+readonly REFRESH_BACKGROUND_MODE="fetch"  # Lines carrying this sentinel are exempt from the literal sweep, and only in the # Makefile: `make` cannot expand Xcode build settings and the device-warning@@ -407,6 +420,11 @@ check_project_level_settings() {         elif [ "$derived" != "$EXPECTED_CONTAINER_DERIVATION" ]; then             fail "$CONTAINER_SETTING for $name is '$derived', expected '$EXPECTED_CONTAINER_DERIVATION'"         fi+        if ! derived="$(pbx "objects.$uuid.buildSettings.$BACKGROUND_TASK_SETTING")" || [ -z "$derived" ]; then+            fail "$BACKGROUND_TASK_SETTING is not declared in the project's $name configuration"+        elif [ "$derived" != "$EXPECTED_BACKGROUND_TASK_DERIVATION" ]; then+            fail "$BACKGROUND_TASK_SETTING for $name is '$derived', expected '$EXPECTED_BACKGROUND_TASK_DERIVATION'"+        fi          # The mirroring gate: declared, at project level, and one of the two         # answers the runtime reader understands. An absent or misspelled value@@ -438,7 +456,7 @@ check_project_level_settings() { # The identity-setting keys a configuration assigns, one per line, conditional # variants included. Enumerating the buildSettings keys is what makes # `ASTERISM_IDENTITY[sdk=iphoneos*]` visible at all — and it is one plutil call-# for all four settings instead of one per setting.+# for every identity setting instead of one per setting. # # ASTERISM_XCENT_SUFFIX is deliberately SDK-conditional (verify-build-identity.sh # explains why) and is not an identity setting, so it is not in scope here.@@ -447,7 +465,7 @@ identity_keys_in() {     while IFS= read -r key; do         [ -n "$key" ] || continue         for setting in "$IDENTITY_SETTING" "$APP_GROUP_SETTING" "$CONTAINER_SETTING" \-            "$MIRRORING_SETTING"; do+            "$MIRRORING_SETTING" "$BACKGROUND_TASK_SETTING"; do             case "$key" in                 "$setting" | "$setting"'['*) printf '%s\n' "$key" ;;             esac@@ -680,24 +698,41 @@ plutil_keypath() {     printf '%s' "${1//./\\.}" } -# The array must hold exactly the one reference: exactly, because an entitlement-# listing both configurations' App Groups is the catastrophic case and a-# containment check passes it (Q6).-check_entitlements_array() {-    local path="$1" key="$2" expected="$3" actual keypath+# A plist array that must hold exactly one element, and that element must be the+# build-setting reference: exactly, because a list naming both configurations'+# values is the catastrophic case and a containment check passes it.+#+# The shape is shared by the entitlement arrays and by+# BGTaskSchedulerPermittedIdentifiers; the consequence of getting each one wrong+# is not, so the caller supplies the tail of the three messages. `plutil -extract`+# on the array itself prints the whole array, so the element is addressed by+# index: `.0` for the one that must be there, `.1` for the one that must not.+check_single_reference_array() {+    local path="$1" key="$2" expected="$3" missing_tail="$4" drift_tail="$5" extra_message="$6"+    local actual keypath     keypath="$(plutil_keypath "$key")"     if ! actual="$(plutil -extract "$keypath.0" raw -o - "$path" 2>/dev/null)"; then-        fail "$path has no $key entry"+        fail "$path has no $key entry$missing_tail"         return     fi     if [ "$actual" != "$expected" ]; then-        fail "$path declares $key as '$actual', expected the reference '$expected' — a literal here can drift from the declaration"+        fail "$path declares $key as '$actual', expected the reference '$expected'$drift_tail"     fi     if plutil -extract "$keypath.1" raw -o - "$path" >/dev/null 2>&1; then-        fail "$path declares more than one $key value; exactly one is allowed (Q6)"+        fail "$path $extra_message"     fi } +# The entitlement wording: an entitlement listing both configurations' App Groups+# is what Q6 is about.+check_entitlements_array() {+    local path="$1" key="$2" expected="$3"+    check_single_reference_array "$path" "$key" "$expected" \+        "" \+        " — a literal here can drift from the declaration" \+        "declares more than one $key value; exactly one is allowed (Q6)"+}+ # ------------------------------------------------------------------------------ # Check 3b — a macOS-capable target signing with iOS-flavoured entitlements has #            to name a macOS entitlements file (Q22).@@ -800,6 +835,18 @@ check_target_info_plist() {             # configuration declared. A literal YES/NO here is a second             # declaration that a flip would leave behind.             check_plist_reference "$path" "$MIRRORING_PLIST_KEY" "\$($MIRRORING_SETTING)"+            # The background-export task, all three keys (background-export+            # Req 3.4, Q22). The identifier reaches `BackgroundExportScheduler`+            # as a reference like every other identity key; BGTaskScheduler+            # then refuses any identifier the bundle does not also *permit*,+            # and iOS grants no refresh at all without the background mode. A+            # break in any one of the three is silent at build time and shows+            # up as a feature that simply never runs.+            check_plist_reference "$path" "$BACKGROUND_TASK_PLIST_KEY" "\$($BACKGROUND_TASK_SETTING)"+            check_permitted_identifiers "$path" "\$($BACKGROUND_TASK_SETTING)"+            if ! plist_array_contains "$path" "$BACKGROUND_MODES_PLIST_KEY" "$REFRESH_BACKGROUND_MODE"; then+                fail "$path does not list '$REFRESH_BACKGROUND_MODE' in $BACKGROUND_MODES_PLIST_KEY; without it iOS grants no app-refresh time and the background export never runs (Req 3.1)"+            fi         else             # An extension's Bundle.main is its own .appex, so it carries its             # own derived App Group key — derivation from the one declaration,@@ -814,6 +861,15 @@ check_target_info_plist() {             if plutil -extract "$MIRRORING_PLIST_KEY" raw -o - "$path" >/dev/null 2>&1; then                 fail "$path carries $MIRRORING_PLIST_KEY; an extension never mirrors (Req 5.1)"             fi+            # background-export Non-Goal 2: the extension never asks for+            # background time and never registers a task. It only leaves the+            # marker; the app's grant is what acts on it.+            if plutil -extract "$BACKGROUND_TASK_PLIST_KEY" raw -o - "$path" >/dev/null 2>&1; then+                fail "$path carries $BACKGROUND_TASK_PLIST_KEY; only the app registers the background export task"+            fi+            if plutil -extract "$PERMITTED_IDENTIFIERS_PLIST_KEY" raw -o - "$path" >/dev/null 2>&1; then+                fail "$path carries $PERMITTED_IDENTIFIERS_PLIST_KEY; an extension schedules no background task"+            fi         fi     done <<< "$(target_setting_values "$uuid" "$target" "INFOPLIST_FILE")" }@@ -859,6 +915,31 @@ check_plist_reference() {     fi } +# BGTaskSchedulerPermittedIdentifiers holds exactly the one reference — the same+# one the scalar key carries. The shape is `check_single_reference_array`'s; only+# the wording differs, because what has gone wrong when this array is empty or+# holds two identifiers is not an entitlement and not Q6.+check_permitted_identifiers() {+    local path="$1" expected="$2"+    check_single_reference_array "$path" "$PERMITTED_IDENTIFIERS_PLIST_KEY" "$expected" \+        "; BGTaskScheduler refuses to register or grant an identifier the bundle does not permit, silently" \+        " — a literal here can drift from the declaration, and a permitted list naming the other configuration's task is a build that registers a task it may not run" \+        "permits more than one background task identifier; this app declares exactly one (Decision 2)"+}++# Whether a plist array names a value as a whole element. Indexed rather than+# matched against the printed array, so a value that is a substring of a+# neighbour cannot pass for it.+plist_array_contains() {+    local path="$1" key="$2" wanted="$3" keypath index=0 value+    keypath="$(plutil_keypath "$key")"+    while value="$(plutil -extract "$keypath.$index" raw -o - "$path" 2>/dev/null)"; do+        [ "$value" = "$wanted" ] && return 0+        index=$((index + 1))+    done+    return 1+}+ # ------------------------------------------------------------------------------ # Check 5 — no full composed literal anywhere in the tracked tree. #@@ -873,7 +954,9 @@ check_literal_sweep() {         "group.$EXPECTED_TOKEN_DEVELOPMENT" \         "group.$EXPECTED_TOKEN_PERSONAL" \         "iCloud.$EXPECTED_TOKEN_DEVELOPMENT" \-        "iCloud.$EXPECTED_TOKEN_PERSONAL"+        "iCloud.$EXPECTED_TOKEN_PERSONAL" \+        "$EXPECTED_TOKEN_DEVELOPMENT.backgroundExport" \+        "$EXPECTED_TOKEN_PERSONAL.backgroundExport"     do         sweep_for "$value"     done
specs/OVERVIEW.md Modified +18 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 4c09537..93967cc 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -35,6 +35,7 @@ | [Character Ranking](#character-ranking) | 2026-08-30 | Done | T-2273. Orders a work's characters by prominence instead of name: each character's facts are bucketed by story position, scored `Σ 2^(-d/10) · log2(n+1)` with `d` the ordinal distance from the latest chapter, and ranked descending with name order as the tie-break. One derived order for the work page and the share sheet (overrules share-sheet-characters Q1); no schema change. | | [Stats Period Navigation](#stats-period-navigation) | 2026-08-30 | Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation); `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. Q16's `Menu` fallback was **not** needed: the capsule passes the no-clipping assertion at `AccessibilityExtraExtraExtraLarge` (Q26) | Smolspec (T-2216). Replaces the Stats page's five-period `Menu` with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history is reachable directly and clamped at both ends; an All-time bar switches to that month in place of the pushed month screen. Adds two top-five ranked lists for the shown period — most-read works and most-read sites (capture hostnames), counted on first capture. App-layer only; supersedes in part `stats-page` Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, its site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55, and rewrites design §5.4. | | [iPad and Mac Layouts](#ipad-and-mac-layouts) | 2026-08-28 | Done — all 34 tasks implemented 2026-09-01 across five phases and four review-fix rounds; `make verify-identity`, `make test-quick` (with the Mac build and appex), `make test-ui-ipad` (12/12) and the iPhone journeys green (pre-existing M4Scale sim trio excepted). Remaining the owner's: the 46-row manual Mac/iPad checklist in `verification-run.md` (the Mac sky is still visually unverified), and four open questions — Req 1.7's pane-wide pushes (F4), the detail column's missing title (A10/F6), Req 6.1's wording (C4, Q49), and Req 9.4 vs `AdaptiveColorTests` (G1). T-2298 filed for the pre-existing phone Stats accessibility breach the new suite exposed | T-2286. Gives the iPad and the Mac a layout of their own — a sidebar with the three tabs beside list and detail columns, collapsing to the phone layout as the window narrows — and brings the app and a share extension to the Mac as a native SwiftUI build against the same CloudKit-mirrored library. Navigation state moves into one `AppNavigation` object owned by the App; two files hold every platform conditional; a spool directory watcher and a visibility-based lifecycle replace the phone's activation semantics on the Mac. Design canvas in `docs/ipad-and-mac/`. |+| [Background Export](#background-export) | 2026-09-02 | Done — all 12 tasks implemented 2026-09-03 across four phases (Core, App, Extension, Documentation). Device verification remains the owner's: the eight-step runbook in `runbook.md`, `Development` first and `Personal` last after a container download, every step approved at the moment of running | Full spec (T-2052). An iOS app-refresh background task that lets the app's CloudKit mirror export captures the share extension committed with mirroring off, so a share on the phone reaches other devices without the app being opened. The extension leaves one empty UUID-named marker file per commit; a marker is settled by any successful export whose start is later than the marker's creation, checked against a persisted export start before any wait. The pass is a mode of `AppLibraryModel`: it reuses a live library or opens and shuts one of its own, and a foreground open pre-empts it. Refresh-only, 20 s budget, iOS only (BackgroundTasks does not exist on macOS); the Mac keeps the foreground marker clearing. |  --- @@ -592,3 +593,20 @@ T-2286 (2026-08-28). Split-view layouts for the iPad and a native Mac app plus M - [prerequisites.md](ipad-and-mac-layouts/prerequisites.md) - [verification-run.md](ipad-and-mac-layouts/verification-run.md) - [implementation.md](ipad-and-mac-layouts/implementation.md)++## Background Export++Full spec (T-2052). An extension capture reaches CloudKit only when the app is next foregrounded (cloudkit-mirroring Req 5.4). The app now keeps an app-refresh request pending whenever it is not in the foreground; a grant runs a pass that exports what the extension left behind and releases the library before the grant ends. The extension still never mirrors.++- **The pass is a mode of the model, and the foreground pre-empts it** (Decision 1): resident and ready → reuse the live repository and monitor; cold launch → open through the existing two-phase `openForApp` into a separate session and shut it down; `bootstrap()` cancels a pass in flight and waits only for the cancelled wait to unwind. One flag set synchronously and a drain loop keep the two openers exclusive (cloudkit-mirroring Q24).+- **App refresh only** (Decision 2): up to 30 s grants that arrive during the day; 20 s pass budget; expiry reaches the pass through a cancellation handler on every joiner (Q30).+- **Marker = a directory of empty UUID-named files** (Decision 3, Q8): created after the commit is durable; settled by any successful export whose *start* is later than the file's creation, from a persisted `lastExportStarted` before any wait and from a live, cancellable `awaitExport` for the rest. Best-effort, with the failure bounded to the old latency for one capture (Q24, Q31).+- **Nothing else runs in the background**: arrivals during a pass defer to the next activation; the pending-capture queue is not drained (Q3); no new UI beyond a `Development`-only Settings trigger (Q5, Q6).+- **Verification is a device runbook**, `Development` first and `Personal` last after a container download; every device run needs approval at the moment of running.++- [requirements.md](background-export/requirements.md)+- [design.md](background-export/design.md)+- [tasks.md](background-export/tasks.md)+- [decision_log.md](background-export/decision_log.md)+- [prerequisites.md](background-export/prerequisites.md)+- [runbook.md](background-export/runbook.md)
specs/background-export/decision_log.md Added +151 / -0
diff --git a/specs/background-export/decision_log.md b/specs/background-export/decision_log.mdnew file mode 100644index 0000000..ef1b3de--- /dev/null+++ b/specs/background-export/decision_log.md@@ -0,0 +1,151 @@+# Decision Log: Background Export++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-02 | Full spec workflow, not smolspec | The central choice — who owns the mirrored store during a background pass when a foreground open can arrive mid-pass — is an ADR, not a task-list row. Requirements ambiguity (queue drain, visibility) and Info.plist background declarations fire the other two triggers mildly |+| Q2 | 2026-09-02 | Spec name `background-export` | Names the mechanism: an app-side background pass that exports pending changes. `extension-capture-sync` and `background-mirror-refresh` considered |+| Q3 | 2026-09-02 | The pass exports only; it does not drain the pending-capture queue | A drain needs the app-side prepare path (title fetch, identity lookup) and a ready `AppLibraryModel`; the pass should be short and off the network. Preserved records are already safe on disk and drain at next foreground activation. `specs/pending-capture-queue/` lists T-2052 as a non-goal in the other direction |+| Q4 | 2026-09-02 | The extension writes an App Group marker after a successful commit; a grant with no marker opens nothing | The ticket's option 2 composes with option 1 rather than replacing it: the extension cannot wake the app, but it can make a grant the app already holds cheap to skip. Alternative — always open on every grant — pays the open cost for nothing when the reader has not shared |+| Q5 | 2026-09-02 | Passes are logged under `category:BackgroundExport`; no Settings sync-status change | User preference. A Settings line was offered and declined; logs are what a trace of a missing capture needs |+| Q6 | 2026-09-02 | `Development` builds get a Settings control that runs the pass on demand | Grants cannot be exercised on the simulator; a manual trigger lets the pass logic be checked on a device without waiting for iOS |+| Q7 | 2026-09-02 | Export completion is judged from mirror activity, not from the rows: a pass ends on export activity that *began* after the marker was found and finished without error, under a hard budget | Requirements review (critic + Codex + Kiro). `NSPersistentCloudKitContainer` reports export events with dates and success only, never which records they carried; an export already in flight when the pass starts may or may not carry the capture, and a store with nothing pending emits no event at all, so an unbounded wait hangs to expiry. Recorded as a non-goal so nobody later reads "exported" as proof |+| Q8 | 2026-09-02 | Every commit produces a distinguishable marker, and the app clears only the marker it found before the observed export began (compare-and-clear) | Review found the race: pass observes an export, extension commits and marks, pass clears — capture stranded until the next foreground open, the bug this spec exists to fix. A timestamp compared against the export's *end* is unsafe (an export that started before the commit can end after it), so the rule is stated against the export's start and the marker's identity, not its time |+| Q9 | 2026-09-02 | The foreground clears the marker too | Otherwise every grant after ordinary use opens the library for nothing, since the foreground mirror already exported. Same compare-and-clear rule |+| Q10 | 2026-09-02 | Refresh versus processing task, and whether both are submitted, is a design decision | Peers split: a refresh grant's ~30 s is tight for certification plus a CloudKit round trip, a processing grant prefers idle-and-charging and defeats the latency goal. Requirements bound the pass instead: no external power required, bounded wait, clean yield on expiry |+| Q11 | 2026-09-02 | A pass may perform the open path's own repairs (certification, relationship pass, work-type seeding); it may not touch captures, teaching, or settings | Review: "makes no change of its own" was false as written, since every app open writes those. They are idempotent and already run on every foreground open |+| Q12 | 2026-09-02 | A ~30 s `beginBackgroundTask` window on app backgrounding was proposed by a peer as a cheaper first lever; **rejected** | It runs when the *app* backgrounds. The capture happens later, in the extension, while the app is suspended or gone; no app-side window at backgrounding time can see it. The extension cannot wake the app (non-goal), so a scheduler grant is the only mechanism available |+| Q13 | 2026-09-02 | The pass imports as well as exports, and arrivals reconcile at the next foreground activation | Peer (validator) note: the mirror cannot be opened export-only. Stated in the non-goals so the reconciliation exclusion is honest |+| Q14 | 2026-09-02 | Device verification runs on `Development` first, `Personal` last and only after a container download | The review noted the `Personal` run installs over the real library; `Development` mirrors to its own container and cannot touch it. Performance is not the question here, so the usual "measure in `Personal`" rule does not apply |+| Q15 | 2026-09-02 | "No change to sync status" replaced by "no new reader-facing UI" | The monitor that reports mirror events persists the record Settings shows (cloudkit-mirroring Req 8.1); a pass that observes export necessarily updates it. The user's decision (Q5) was about adding UI, which stands |+| Q16 | 2026-09-02 | Scheduling is iOS-only; the Mac compiles only the foreground marker clearing | The user first chose "all platforms"; Apple's documentation lists `BGTaskScheduler`, both request classes and SwiftUI's `BackgroundTask.appRefresh` for iOS, iPadOS, Mac Catalyst, tvOS and visionOS — not macOS, and the Mac app is native AppKit-hosted SwiftUI. A launchd agent was offered and declined as out of scope. A Mac process stays resident, so its mirror exports whenever the app runs |+| Q17 | 2026-09-02 | The Development-only trigger is gated on `#if DEBUG` | Nothing in Swift exposes the build configuration; `DEBUG` is set on the `Development` configuration only (`project.pbxproj:858`), and the UI-test seam already uses the same gate |+| Q18 | 2026-09-02 | The marker directory uses `completeUntilFirstUserAuthentication` | A refresh grant usually arrives on a locked phone. The spool's `completeUnlessOpen` would make the marker unreadable exactly then; the store itself is on the first-unlock class, and the marker holds no reader content (empty files named by UUID) |+| Q19 | 2026-09-02 | `SyncMonitor.ingest` gets a public spelling, `observe(_:)` | The app's test bundle imports `AsterismCore` without `@testable`, and `NSPersistentCloudKitContainer.Event` cannot be constructed, so the app-level tests for the pass need a public way to feed a completed export event |+| Q20 | 2026-09-02 | Pass budget 20 s from the moment the marker is read | A refresh grant is about 30 s; the open is budgeted at 2 s (cloudkit-mirroring Req 9.1) and the shutdown is synchronous, so 10 s of margin covers both plus SwiftUI's completion. The deadline is what ends a pass whose marker is stale and whose mirror therefore emits nothing |+| Q21 | 2026-09-02 | The refresh request is submitted on `scenePhase == .background` and after every pass | Submitting replaces a pending request with the same identifier, so both triggers are idempotent and together keep one request pending whenever the app is not in the foreground. `AppLifecycle.willResignActive` fires on every Control Centre pull and was not needed |+| Q22 | 2026-09-02 | The task identifier is `$(ASTERISM_IDENTITY).backgroundExport`, declared once as a build setting and referenced from the plist twice | Same substitution the App Group and container keys use, so the identity lint extends naturally; identifiers are per-bundle so the suffix is convention, not a collision guard |+| Q23 | 2026-09-02 | Foreground clearing is armed at bootstrap end and on every activation, not on every remote change | A marker written while the app is resident but never re-activated is settled by the next grant's persisted check (Q24). A per-notification re-arm would list the directory on every hydration transaction for no benefit |+| Q24 | 2026-09-02 | A marker is settled by any successful export whose *start* is later than the marker's *creation date*; the latest successful export start is persisted in `SyncStatusRecord.lastExportStarted` and checked before any wait | Explain-like self-review of the first design found a hole: the rule "an export that started after the pass listed the marker" never clears a marker whose export ran before anyone was watching (the mirror reacting to a wake faster than the handler, a process killed mid-pass), so every later grant burned its budget on it. Keying on the marker's own time instead of the pass's, and persisting the export start, settles those at listing time with no library open. Both stamps come from the device clock; a backwards step can settle a marker early, costing the pre-feature latency for one capture, never the capture |+| Q25 | 2026-09-02 | `.skipped(.alreadyExported)` is a distinct outcome | It is the persisted half of Q24 doing its job and the reason a grant did not open the library; folding it into "no marker" would hide the one case the runbook most needs to see |+| Q26 | 2026-09-02 | On the cold path the `SyncMonitor` starts *before* `openForApp` | The monitor observes notifications only and the certification container emits none, so starting early is free; starting after the open could miss a setup or export event that completed during the open |+| Q27 | 2026-09-02 | Req 1.3 reworded from "end at its budget" to "end no later than its budget": a failed export or setup event ends the pass early as `.failed` | Design review: holding the library for the rest of the budget after the mirror has said it will not export wastes the grant. The requirement's bound is unchanged; ending earlier satisfies it |+| Q28 | 2026-09-02 | Two overlapping `bootstrap()` calls on a multi-scene iPad are a pre-existing hazard, **out of scope** here | Peer validation found that `ContentView.task { bootstrap() }` runs per scene against one shared model and nothing excludes one bootstrap from another's open. This feature's exclusion is between `bootstrap()` and the pass, and it leaves that hazard exactly as it found it. Worth its own ticket |+| Q29 | 2026-09-02 | The resident path relies on the mirror exporting on resume, with the runbook as the proof and "back to design" as the fallback | Design critic: the design assumed the resident mirror would export "on its own" without naming why. The mechanism is Core Data's cross-process remote-change notification, coalesced and delivered when a suspended process resumes — the same path the foreground open takes, which cloudkit-mirroring's runbook observed carrying extension captures. A background grant is a resume. If the runbook's resident step ends `.timedOut`, the only in-spec fallback (tear down and reopen) contradicts Req 1.6, so the row goes back to design rather than being patched |+| Q30 | 2026-09-02 | Cancellation reaches the shared pass through `withTaskCancellationHandler` on every joiner, and the outcomes distinguish `.expired` (system) from `.preempted` (foreground bootstrap) | Design critic's blocker: awaiting a stored `Task`'s value does not propagate the awaiting task's cancellation, so SwiftUI's expiry would never have reached the wait and the pass would have run past the grant. Any joiner's cancellation now cancels the pass. The split outcome exists so the log can tell an expiry from the reader opening the app |+| Q31 | 2026-09-02 | The settlement rule is documented as best-effort: an export enqueued before a commit can start after the marker without carrying it | Peer validation (three systems, unanimous): an export event's start date is the activity's start, not a documented history fence. The consequence is bounded to the pre-feature latency for one capture — the mirror's own token still precedes the commit — so the rule stays and the claim of a guarantee goes. The runbook's two-device pass watches for it |+| Q32 | 2026-09-02 | `BackgroundExportLogLine` carries `phase`, `passID`, `listed`, `settled`, and on the end line `outcome` and `elapsed`; a pass whose `pending()` throws still emits both lines (with zero counts) | The design named the type but not its fields. Two lines per pass, always, keeps a Console trace pairable by identifier even when the directory could not be read (Req 4.1) |+| Q33 | 2026-09-02 | `.failed`'s message is read from `SyncStatusRecord.lastFailure.message` after the wait resolves, with a generic sentence as the fallback | `ExportWaitResult.failed` carries no text; `ingest` records the failure before it resolves the waiter, so the persisted record is the one source of the mirror's own error and no second channel is needed |+| Q34 | 2026-09-03 | The Development trigger is gated on `#if DEBUG` alone, not `#if os(iOS) && DEBUG`; a `Development` Mac shows the row too | `ipad-and-mac-layouts` Req 4.5 confines `#if os(` to four named files and `PlatformSeamTests` enforces it; the design's spelling failed that suite. `DEBUG` is Q17's gate and carries the whole of Req 4.2 (Development shows it, Personal does not). The pass is platform-neutral, so the Mac row runs the same pass against its resident library; the scheduler stays iOS-only |+| Q35 | 2026-09-03 | The trigger is wired as a `runBackgroundExport` closure handed to `SettingsView`, which owns the `BackgroundExportTriggerModel` as `@State`; the design's `AppLibraryModel.backgroundExportTriggerModel()` factory was dropped | An initializer parameter cannot be `#if`-gated but `BackgroundExportOutcome` is unconditional, so a closure compiles in every configuration and the *row* is what the gate withholds. Settings' inputs are rebuilt on every re-render, so the view owns the model's state, as it does `syncModel`. The factory ended up with no caller |+| Q36 | 2026-09-03 | Test seams beyond the design's `mirroringOpenHooks`: `SyncMonitor.isObserving` (new, public), `SyncMonitor.pendingExportWaiterCount` (promoted to public), `AppLibraryModel.hasOpenForegroundRepository`, an optional `configuration:` on `init(readyRepository:)`, `private(set)` visibility on the four new background-export fields, and `bootstrap()` passing `mirroringOpenHooks` to `openForApp` | The app's test bundle imports Core without `@testable`. A released pass must leave nothing observing (Req 1.5), arming a wait is one actor hop from the call that arms it, and Req 1.8's "two containers never" is only assertable as a count if both openers construct through the same hooks |+| Q37 | 2026-09-03 | `ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER` is also added to `verify-identity.sh`'s `identity_keys_in()`, a fifth lint addition beyond the design's four | Without it the conditional-key check and `check_no_shadow_assignments` would not cover the new setting, unlike every other identity setting; a target-level shadow would go unlinted |+| Q38 | 2026-09-03 | In `AsterismApp`, the `.backgroundTask` and `.onChange(of: scenePhase)` modifiers sit unguarded in `body`'s existing `#else` (not-macOS) branch; only the `scenePhase` and `scheduler` properties carry `#if os(iOS)`. `PlatformSeamTests` splits its one list into `iOSOnlyFiles` (whole-file wrap: three files) and `uiKitBridgeFiles` (`import UIKit`: the same two as before) | Swift rejects a postfix `#if` nested inside that `#else`. The app target's `SUPPORTED_PLATFORMS` is `iphoneos iphonesimulator macosx`, so not-macOS is iOS, and a fourth platform fails to compile loudly on the properties rather than silently dropping the feature. `BackgroundExportScheduler.swift` is a whole-file wrap that imports no UIKit, so the wrap allowance widened without widening the UIKit one |+| Q39 | 2026-09-03 | The app-side pass, pre-emption, reentrancy and foreground-clearing tests live in `Asterism/AsterismTests/AppLibraryModelBackgroundExportTests.swift`, not in `AppLibraryModelTests.swift` as the design and tasks 7.1/8.1 name | `AppLibraryModelTests.swift` is already ~1,400 lines and carries none of the fixtures these fifteen cases need (the counting mirroring hooks, the blocking factory, the polling barrier); a file of their own keeps them findable. The design's file reference stands as written; this row is the pointer |+| Q40 | 2026-09-03 | A grant that `runBackgroundExport()` turns away before a pass exists — the foreground is opening, or the configuration cannot be resolved — still emits the start/end pair through `BackgroundExportPass.reportUnstarted`, with zero counts and zero elapsed | Pre-push review: Req 4.1 and Q32 say every pass emits two lines and the runbook is read on that assumption; a grant landing mid-bootstrap otherwise produced a Settings sentence and Console silence |++## Decision 1: The pass is a mode of `AppLibraryModel`, and a foreground open pre-empts it++**Date**: 2026-09-02+**Status**: accepted++### Context++A background grant can arrive with the app in three states: resident with a ready library, resident with a failed or opening library, or not running at all (a cold launch with no scene). The requirements demand that a resident library be reused ([1.6](requirements.md#1.6)), that a foreground open arriving mid-pass succeed with no library-unavailable state ([1.7](requirements.md#1.7)), and that two mirrored containers never be open over the store at once ([1.8](requirements.md#1.8), cloudkit-mirroring Q24). `AppLibraryModel` is the only thing that knows whether a repository is live, and `bootstrap()` is the only other opener.++### Decision++`runBackgroundExport()` is a method on `AppLibraryModel`. The pass logic lives in the package (`BackgroundExportPass`) and asks the model for a library session: the model reuses its live repository and monitor when ready, skips when unavailable or opening, and on a cold launch opens through `openForApp` into a separate `backgroundSession` that never touches `repository` or `state`. `bootstrap()` cancels and awaits any pass in flight before its own teardown and open; the pass's wait is cancellable, so the foreground waits milliseconds, not the budget.++### Rationale++The single-container invariant is only provable when every opener is serialised through one owner on one actor. Putting the pass on the model makes the two openers `bootstrap()` and `runBackgroundExport()`, both main-actor methods, each of which awaits the other's task before opening. Pre-emption rather than waiting is chosen because a reader who opens the app should see the library, not a spinner for up to 20 s; the foreground mirror exports the same changes anyway, and the marker survives for the foreground to clear.++### Alternatives Considered++- **A standalone Core opener with no model knowledge**: opens and shuts a repository of its own — Cannot satisfy reuse of a live library, and a foreground bootstrap could run its open concurrently with the pass's, which is the 134422 collision. It would need the model's coordination anyway.+- **Model mode, foreground waits for the pass**: simpler ordering — The reader can wait the full budget on a loading screen; rejected on [1.7](requirements.md#1.7).+- **Foreground adopts the pass's repository**: no second open at all — `bootstrap()` does far more than open (fixtures, queue, snapshots, monitor wiring, reconcile scheduling) and would need a second entry point that starts from a live repository. One extra 2 s open in a rare overlap is cheaper than a second bootstrap path.++### Consequences++**Positive:**+- One owner, one actor, two openers that exclude each other by construction.+- The pass is testable on the host through a fake session; the model's part is testable through the existing `AppLibraryModelTests` fixtures.+- The Development trigger and the scheduler's handler call the same method.++**Negative:**+- `AppLibraryModel` grows a second lifecycle (`backgroundSession`, `backgroundExportTask`) beside the foreground one, and `bootstrap()` gains a pre-emption step every caller pays for.+- A foreground open that lands during the pass's certification waits for it (up to the open's own budget) before its own open can begin.++---++## Decision 2: One app-refresh task, no processing task++**Date**: 2026-09-02+**Status**: accepted++### Context++BackgroundTasks offers two request kinds. App refresh grants are short (about 30 s) and arrive according to how the reader uses the app, several times a day for an app used daily. Processing grants can run for minutes but are scheduled for idle time, typically overnight on a charger. The pass needs an open (≤2 s), mirror setup and one export round trip (seconds, unbounded on a bad network), and a shutdown.++### Decision++Request only `BGAppRefreshTaskRequest`, with no `earliestBeginDate`, and bound the pass to 20 s.++### Rationale++The feature exists for latency. Refresh grants are the ones that arrive during the day; a processing grant that fires at 03:00 delivers the capture to the iPad the reader stopped using at 23:00. The work fits a refresh grant comfortably in the common case, and the uncommon case (slow network, large export) is not lost — it ends at the deadline with the marker intact and is retried on the next grant or the next foreground open.++### Alternatives Considered++- **Processing task only**: minutes of runtime — Scheduled for idle and charging; defeats the goal.+- **Both, refresh first, processing as a fallback**: covers the large-export case — A second identifier, a second handler, and a second plist entry for a case that the refresh path already handles by retrying; the fallback would mostly fire overnight after the refresh had already succeeded.++### Consequences++**Positive:**+- One identifier, one handler, one lint rule, one runbook path.+- The budget is fixed and the tests can pin it.++**Negative:**+- An export that needs more than ~18 s of network time never completes in the background on that device; it waits for the foreground.+- Requests can only be submitted synchronously through `submit(_:)` on the deployment target; if a later OS deprecates it, the scheduler is the one place to change.++---++## Decision 3: The marker is a directory of empty UUID-named files++**Date**: 2026-09-02+**Status**: accepted++### Context++[2.1](requirements.md#2.1) needs each commit to leave something the app can read without opening the store, distinguishable from earlier commits, and [2.3](requirements.md#2.3) needs the app to clear only what it saw. The extension and the app are separate processes with no shared lock beyond the migration lease.++### Decision++`<rootDirectory>/ExportOwed/` holds one empty file per committed capture, named by a fresh UUID. The extension creates a file; the app lists the directory, remembers the names, and after an observed export unlinks exactly those names.++### Rationale++Creating and unlinking distinct names are each atomic at the filesystem, so compare-and-clear needs no lock and no content: a name created after the listing is not in the set and survives. This is the pending-capture queue's "the directory layout is the state" pattern (its Decision 7), which the codebase already trusts across these two processes.++### Alternatives Considered++- **One file holding a token, replaced atomically on each commit**: smaller — Clearing requires read-compare-unlink, and an extension write between the compare and the unlink is lost. Closing that window needs a lock the extension would have to take on every commit.+- **A timestamp file compared against the export's end date**: no identity needed — Unsafe: an export that started before the commit can end after it (Q8).+- **A row in the store**: no new file — The whole point is reading without opening the store.++### Consequences++**Positive:**+- No lock, no content, no format to version; a stale file costs one pass that ends at its deadline.+- The extension's write is one `createFile` and cannot delay `completeRequest` measurably.++**Negative:**+- Files accumulate if the app never observes an export (no iCloud account for weeks); they are empty and bounded by the number of captures, but nothing prunes them until an export succeeds.+- The directory is one more App Group path the identity of which the extension and app must agree on, through `LibraryConfiguration` as with every other.++---
specs/background-export/design.md Added +297 / -0
diff --git a/specs/background-export/design.md b/specs/background-export/design.mdnew file mode 100644index 0000000..fde3a8e--- /dev/null+++ b/specs/background-export/design.md@@ -0,0 +1,297 @@+# Design: Background Export++**Ticket:** T-2052 · Requirements: [`requirements.md`](requirements.md) · Decisions: [`decision_log.md`](decision_log.md)++## Overview++The app registers one app-refresh background task on iOS and keeps a request for it pending whenever it is not in the foreground. When iOS grants it, `AppLibraryModel` runs a *pass*: it reads a marker directory the share extension writes into after every commit, settles every marker an earlier export already covered, and if anything is still owed makes the library available to the CloudKit mirror — reusing the live repository when the app is resident, opening and shutting one of its own when it is not — until the mirror reports an export that started after the newest marker, or the budget runs out. The extension never mirrors; it only leaves the marker.++## Architecture++### Where the pieces live++| Piece | Module | File | Role |+|---|---|---|---|+| `ExportOwedMarker` | AsterismCore | `ExportOwedMarker.swift` | The App Group marker directory: mark, list with creation dates, clear by name |+| `SyncMonitor.awaitExport`, `SyncEvent.startDate`, `SyncStatusRecord.lastExportStarted` | AsterismCore | `SyncMonitor.swift`, `SyncStatus.swift` | The export-start rule, live (cancellable wait) and persisted (status record) |+| `BackgroundExportPass` | AsterismCore | `BackgroundExportPass.swift` | The pass: settle → marker → library session → wait → clear; outcomes and logging |+| `AppLibraryModel.runBackgroundExport()` | Asterism | `ViewModels/AppLibraryModel.swift` | The library session the pass runs against: reuse, open, pre-emption; foreground clearing |+| `BackgroundExportScheduler` | Asterism, `os(iOS)` | `Support/BackgroundExportScheduler.swift` | Submits the refresh request; logs refusal once per process |+| Handler + scene-phase hook | Asterism, `os(iOS)` | `AsterismApp.swift` | `.backgroundTask(.appRefresh(id))` and submit-on-background |+| Marker write | AsterismShareExtension (both extension targets) | `ShareCaptureSession.swift` | After a committed capture's spool record is discarded |+| Development trigger | Asterism, `os(iOS) && DEBUG` | `Views/SettingsView.swift`, `ViewModels/BackgroundExportTriggerModel.swift` | Runs the same pass on demand, result inline |+| Identity lint | scripts | `scripts/verify-identity.sh` | The task identifier is a per-configuration plist reference |++The pass logic and every decision it makes are in the package so `make test-core` proves them with fakes. The app supplies only the library session (Decision 1).++### The settlement rule++A marker stands for one commit and records the moment the extension wrote it, which is after that commit is durable. **A marker is settled by any successful export whose start is later than the marker's creation.** The rule is on the export's *start* and never its end (Q8), and it is applied in two places:++- **Persisted.** `SyncMonitor` records the start date of the latest successful export in `SyncStatusRecord.lastExportStarted`. Any marker older than it is settled the moment it is listed, with no library open and no wait. This is what clears a marker whose export ran while nobody was watching — the mirror reacting to a wake before the handler ran, an app terminated mid-pass, a foreground session that was never activated again.+- **Live.** `SyncMonitor.awaitExport(startedAfter:deadline:)` resolves on the first completed export whose start is later than the threshold. The threshold is the creation date of the *newest* outstanding marker, so an export already in flight when the pass began counts if it started after that marker.++Clearing is by name. A marker created after the listing is not in the listed set and survives whatever the pass does ([2.3](requirements.md#2.3)).++**The rule is best-effort, and its failure is bounded.** An export event's start date is the start of the mirror's export *activity*; Apple does not define it as the upper bound of the history that activity carries, so an export the mirror enqueued before a commit can start after the marker and succeed without it. The same early settlement follows from a device clock stepped backwards between the commit and the export. In both cases the marker is gone but the mirror's own history token still precedes the commit, so the capture goes with the next export — at the next foreground open, or the next time the mirror runs for another reason. The cost is the pre-feature latency for one capture, never a lost capture and never a duplicate (Q24). The runbook's two-device pass watches for it; nothing in the design depends on it never happening.++### The pass++```mermaid+sequenceDiagram+    participant iOS+    participant App as AsterismApp+    participant M as AppLibraryModel+    participant P as BackgroundExportPass+    participant K as ExportOwedMarker+    participant S as SyncMonitor+    iOS->>App: appRefresh grant (cold launch or resident wake)+    App->>App: scheduler.submit()  — first, so an expiry kill leaves a request pending+    App->>M: runBackgroundExport()+    M->>P: run(session: self)+    P->>K: pending() → entries (name, createdAt)+    P->>P: settle: clear entries older than status.lastExportStarted+    alt nothing outstanding+        P-->>M: .skipped(.noMarker) or .skipped(.alreadyExported)+    else outstanding, threshold = newest createdAt+        P->>M: session.acquire()+        Note over M: resident+ready → reuse repository & monitor, gate arrivals<br/>nothing open → start SyncMonitor, then openForApp+        M-->>P: monitor, or a skip reason, or .cancelled+        P->>S: awaitExport(startedAfter: threshold, deadline: t0+budget)+        S-->>P: .exported | .failed | .deadline | .cancelled | .stopped+        P->>K: clear(outstanding) — only on .exported+        P->>M: session.release()+        Note over M: shutdown() only what the pass opened; lift the arrivals gate+        P-->>M: outcome+    end+    M-->>App: outcome+    App->>App: scheduler.submit() again, then return+```++**Budget.** `BackgroundExportBounds.passBudget = 20 s` from `t0`, the moment the pass starts, on the repository clock. A refresh grant is *up to* 30 s; the remaining margin covers the open (budgeted at 2 s by cloudkit-mirroring Req 9.1, plus up to the 5 s lock timeout if the extension is mid-capture) and the shutdown. The deadline is the *only* thing that ends a pass whose mirror has nothing to say ([1.3](requirements.md#1.3)): a store with nothing pending emits no export event.++**Expiry and cancellation.** SwiftUI's `backgroundTask(_:action:)` cancels the handler's task when the grant is about to expire. Cancellation has to reach the pass through two layers:++1. `runBackgroundExport()` stores the pass in `backgroundExportTask` so that concurrent callers join it, and every caller awaits it through `withTaskCancellationHandler(operation: { await task.value }, onCancel: { task.cancel() })`. *Any* joiner's cancellation expires the shared pass — the grant's handler, a `bootstrap()`, or the Development trigger's view going away. The handle is cleared inside the task as its last act, so a later call starts a fresh pass rather than joining a finished one.+2. Inside the pass, `awaitExport` resumes with `.cancelled` on cancellation (see the waiter shape below), and `run` checks `Task.isCancelled` immediately after `acquire()` returns. The cold open is cancellable only while it waits for the cross-process lock (`CrossProcessLibraryLock` checks cancellation between retries and throws `CancellationError`); certification after the lock is not. `acquire()` maps `CancellationError` to `.cancelled`, which the pass reports as `.expired` or `.preempted`, never as unavailable.++The handler submits the next request *before* the pass as well as after it: on a cold launch `scenePhase` never reaches `.background`, so a process killed at expiry would otherwise leave nothing pending ([3.1](requirements.md#3.1)).++**Resident versus cold.**++| Situation on grant | `acquire()` does | `release()` does |+|---|---|---|+| `state == .ready`, `repository != nil` | Reuses `repository` and `syncMonitor`. `.skipped(.bulkOperation)` if `repository.isBulkOperationInProgress()`. `.skipped(.unavailable("mirroring is not attached"))` if `syncMonitor == nil`. Sets `backgroundPassHoldsLibrary`, which gates `handleSyncArrivals()` (below) | Clears the gate |+| `state == .unavailable(message)` | `.skipped(.unavailable(message))` — the foreground already diagnosed it | Nothing |+| `bootstrapInFlight` | `.skipped(.unavailable("the library is opening"))` — that open's monitor exports on its own; the process is alive | Nothing |+| `repository == nil`, no bootstrap in flight (cold launch) | Builds and **starts** a `SyncMonitor` over the store *before* the open, so the mirror's first events cannot precede the observer; `onArrivals` stays nil. Then `LibraryRepository.openForApp(configuration, mirroring: mirroringOpenHooks)`; `.skipped(.unavailable(reason))` if it throws or `mirroring` is not `.attached`, stopping the monitor and shutting down whatever opened on every failing arm; `.cancelled` on `CancellationError`. Holds both in `backgroundSession`, never in `repository` or `state` | `monitor.stop()`, `repository.shutdown()`, `backgroundSession = nil` — before the pass returns ([1.5](requirements.md#1.5)) |++*What makes the resident mirror export.* The extension's commit posts Core Data's cross-process remote-change notification; a suspended app receives it, coalesced, when it resumes, and the mirror then exports the history it has not yet sent. A background grant resumes the process exactly as a foreground open does, and the foreground open is the path cloudkit-mirroring's runbook already observed carrying extension captures. The runbook proves the resident path directly (app suspended, extension capture, simulated launch, pass ends `.exported` with no activation); a `.timedOut` there sends the resident row back to design, since the fallback — tear down and reopen — contradicts [1.6](requirements.md#1.6) (Q29).++The cold path opens through `openForApp` unchanged: its lease, certification, repairs and work-type seeding are [1.11](requirements.md#1.11)'s permitted writes, and a throwing open — migration pending, unverifiable store, lock timeout, protected store — is the `.unavailable` skip of [1.9](requirements.md#1.9) with the marker intact. The configuration is resolved exactly as `bootstrap()` resolves it (`explicitConfiguration` or `LibraryConfiguration.production(...)`), through one shared private helper.++*Arrivals during a pass.* The cold monitor's `onArrivals` is nil. The resident monitor's `onArrivals` is the foreground's `handleSyncArrivals()`, which reconciles; while `backgroundPassHoldsLibrary` is set it instead records `arrivalsDeferredByPass = true` and returns, and `drainAndReconcile()` runs the deferred `handleSyncArrivals()` before its own refresh at the next activation. That is the reconciliation non-goal made concrete: nothing the mirror imports during a grant is reconciled until the reader comes back.++**Exclusion between the two openers.** The openers in the process are `bootstrap()` and the pass's cold `acquire()`, both main-actor methods on the model, and the main actor is reentrant at every `await`. The protocol therefore uses one flag set synchronously and a drain loop:++```swift+public func bootstrap() async {+    bootstrapInFlight = true                       // first statement, before any await+    defer { bootstrapInFlight = false }+    while let pass = backgroundExportTask {        // a loop: a pass could be joined during the await+        pass.cancel()+        _ = await pass.value+    }+    assert(backgroundSession == nil)+    await teardownRepository()                     // also releases a stray backgroundSession, defensively+    …+}++public func runBackgroundExport() async -> BackgroundExportOutcome {+    if let running = backgroundExportTask { return await joining(running) }+    guard !bootstrapInFlight else { return .skipped(.unavailable("the library is opening")) }+    …store the task; the task's acquire() re-checks bootstrapInFlight…+}+```++`bootstrapInFlight` is read by the pass synchronously before it creates its task and again inside `acquire()`, and it is set by `bootstrap()` before `bootstrap()` first suspends, so a pass cannot slip in between the flag and the drain. A `bootstrap()` arriving mid-pass waits for the cancelled wait to unwind (one main-actor turn) or for a certification already under way (≤ 2 s, or up to the lock timeout), never for the budget (Decision 1). The pass reports `.preempted` in that case, distinguishable in the log from a system `.expired`.++Two `bootstrap()` calls can still overlap each other on a multi-scene iPad today, before and after this feature; that is a pre-existing hazard outside this spec (Q28).++**Foreground clearing ([2.4](requirements.md#2.4)).** `AppLibraryModel.armExportOwedClearing()` applies the same two halves of the rule: it lists and settles against the persisted start, then parks `syncMonitor.awaitExport(startedAfter: newest, deadline: nil)` in a stored task and clears the outstanding names on `.exported`. It is armed at the end of `bootstrap()` (after `startSyncObservation`) and in `drainAndReconcile()` (every activation), replacing any earlier arm; `teardownRepository()` cancels it. A marker written while the app is resident and never activated again is settled by the next grant's persisted check (Q23). On the Mac this is the whole of the feature (Q16).++### The marker++`LibraryConfiguration.exportOwedURL` = `<rootDirectory>/ExportOwed/`. One empty file per committed capture, named by a fresh UUID; its creation date (`URLResourceKey.creationDateKey`) is the marker's time. The directory *and each file* are created with `FileProtectionType.completeUntilFirstUserAuthentication` — a file takes its creator's default class, not its directory's — so a pass on a locked-but-unlocked-once device can read it, which is the class the store itself uses (Q18); the spool's `completeUnlessOpen` is not used because the marker carries no reader content. Listing the directory before first unlock throws, and `pending()` rethrows rather than answering "empty" ([2.6](requirements.md#2.6)). A missing directory *is* "empty".++`ShareCaptureSession` keeps an `ExportOwedMarker?` built in `bootstrap()` step 1 beside the spool, from the same resolved configuration. `finish(discarding:from:outcome:)` calls `mark()` inside the existing `Task { @MainActor in … }`, after `spool.discardPreserved` and before `completeExtension()`, only when `outcome == .committed`; a comment there pins the settlement rule's precondition — the marker is written after the commit is durable and must stay after it. A failed write is logged and ignored ([2.5](requirements.md#2.5)). The Mac extension target compiles the same file and writes markers the Mac app's foreground arm clears.++### Scheduling (iOS only)++`AsterismApp` gains, under `#if os(iOS)`:++```swift+@Environment(\.scenePhase) private var scenePhase+private let scheduler = BackgroundExportScheduler()+…+WindowGroup { … }+    .backgroundTask(.appRefresh(BackgroundExportScheduler.identifier)) { [model] in+        scheduler.submit()+        _ = await model.runBackgroundExport()+        scheduler.submit()+    }+    .onChange(of: scenePhase) { _, phase in+        if phase == .background { scheduler.submit() }+    }+```++The handler captures `model` at body evaluation: `AppLibraryModel` is a class created once in the App's initialiser, so the instance the handler holds on a scene-less launch is the same one `ContentView` installs when a scene later connects — the resident and pre-emption paths rely on that identity, and the cold-launch log line carries the pass identifier so the field run can confirm it. The handler's closure is `@Sendable` with no executor guarantee; both calls hop to the main actor.++`submit()` is synchronous: it builds a `BGAppRefreshTaskRequest` with no `earliestBeginDate` and submits it (`BGTaskScheduler.submit(_:)` is not deprecated on the iOS 26 SDK). The scheduler replaces a pending request with the same identifier, which keeps "at most one" ([3.1](requirements.md#3.1)) free and makes the three submit sites idempotent. A throw is logged under `category:BackgroundExport` once per process (a flag on the scheduler) and otherwise ignored ([3.3](requirements.md#3.3)). The submitter is an injected closure so the once-per-process rule is testable. `.onChange(of: scenePhase)` is per-scene on a multi-window iPad; duplicate submissions are harmless for the same reason.++The identifier is read from the bundle key `AsterismBackgroundExportTaskIdentifier`, whose plist value is `$(ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER)`, a project-level setting `$(ASTERISM_IDENTITY).backgroundExport` in both configurations. `Info.plist` also lists that reference as the single element of `BGTaskSchedulerPermittedIdentifiers` and adds `fetch` to `UIBackgroundModes`. `verify-identity.sh` gains: the setting's pinned value in `check_project_level_settings`; in `check_target_info_plist`'s app arm, `check_plist_reference` on the scalar key, a check that `BGTaskSchedulerPermittedIdentifiers.0` is the same reference and the array has one element, and a check that `UIBackgroundModes` contains `fetch`; the extension arm asserts both keys absent; the literal sweep learns the composed literal ([3.4](requirements.md#3.4)).++### Development trigger++Under `#if os(iOS) && DEBUG` (`DEBUG` is set on the `Development` configuration only), `AppLibraryModel.backgroundExportTriggerModel()` returns a `BackgroundExportTriggerModel` that `SettingsView` shows as a row inside the existing Debug `DisclosureGroup`, following the `backupRow` state-switch pattern: idle button "Run background export" → running `ProgressView` → the outcome sentence, with accessibility identifiers `settings-background-export-run` / `settings-background-export-result`. The model calls `runBackgroundExport()` — the same method the handler calls — so what it exercises is the pass, not a copy of it. `runBackgroundExport()` itself is platform-neutral; on the Mac nothing calls it.++## Components and Interfaces++```swift+// AsterismCore — ExportOwedMarker.swift+public struct ExportOwedMarker: Sendable {+    public struct Entry: Hashable, Sendable { public let name: String; public let createdAt: Date }+    public init(directory: URL)                       // configuration.exportOwedURL+    public func mark() throws                          // extension: creates <UUID> with the protection class, dir if needed+    public func pending() throws -> [Entry]            // app: what is present; missing dir → []+    public func clear(_ names: some Sequence<String>)  // unlink exactly these; ENOENT ignored+}++// AsterismCore — SyncStatus.swift+public struct SyncStatusRecord { …; public var lastExportStarted: Date? }   // added; version stays 1++// AsterismCore — SyncMonitor.swift+public struct SyncEvent { …; public var startDate: Date? }   // added; the ingest path fills it+public enum ExportWaitResult: Equatable, Sendable { case exported, failed, deadline, cancelled, stopped }+extension SyncMonitor {+    /// Resolves on the first completed export or setup event with+    /// startDate > threshold: `.exported` for a successful export, `.failed`+    /// for a failed export or setup. `.deadline` when the optional deadline+    /// passes (one sleeper task per deadline, cancelled on resolve), `.cancelled`+    /// on task cancellation, `.stopped` when `stop()` runs.+    public func awaitExport(startedAfter threshold: Date, deadline: Date?) async -> ExportWaitResult+    /// The public spelling of `ingest`, so the app's tests can feed events.+    public func observe(_ event: SyncEvent)+}++// AsterismCore — BackgroundExportPass.swift+public enum BackgroundExportOutcome: Equatable, Sendable {+    case exported+    case skipped(SkipReason)          // .noMarker, .alreadyExported, .unavailable(String), .bulkOperation+    case timedOut                     // deadline, nothing observed+    case expired                      // cancelled by the system's expiry+    case preempted                    // cancelled by a foreground bootstrap+    case failed(String)               // an export or setup event failed+}+public enum BackgroundExportBounds { public static let passBudget: Duration = .seconds(20) }+public enum BackgroundExportAcquisition { case monitor(SyncMonitor), skipped(SkipReason), cancelled }+public protocol BackgroundExportSession: AnyObject {+    @MainActor func acquire() async -> BackgroundExportAcquisition+    @MainActor func release() async                                   // idempotent+    @MainActor var isPreempting: Bool { get }                         // a bootstrap cancelled us+}+public struct BackgroundExportPass {+    public init(marker: ExportOwedMarker, statusURL: URL,+                clock: any RepositoryClock = SystemRepositoryClock(),+                budget: Duration = BackgroundExportBounds.passBudget,+                log: @escaping @Sendable (BackgroundExportLogLine) -> Void = BackgroundExportLog.emit)+    @MainActor public func run(session: any BackgroundExportSession) async -> BackgroundExportOutcome+    /// The persisted half of the rule, shared with the foreground arm.+    public static func settle(_ entries: [ExportOwedMarker.Entry], against status: SyncStatusRecord,+                              marker: ExportOwedMarker) -> [ExportOwedMarker.Entry]   // returns the outstanding+}++// AsterismCore — LibraryProviding.swift+func isBulkOperationInProgress() async -> Bool      // default false; LibraryRepository reads its flag++// Asterism — AppLibraryModel.swift+extension AppLibraryModel: BackgroundExportSession { … }+public func runBackgroundExport() async -> BackgroundExportOutcome   // joins an in-flight pass; joiner cancellation cancels it+func armExportOwedClearing()                                          // foreground clearing+var mirroringOpenHooks: MirroringOpenHooks = .production              // test seam for the cold path+private var bootstrapInFlight = false+private var backgroundPassHoldsLibrary = false+private var arrivalsDeferredByPass = false+private var backgroundSession: (repository: LibraryRepository, monitor: SyncMonitor)?+private var backgroundExportTask: Task<BackgroundExportOutcome, Never>?++// Asterism (iOS) — BackgroundExportScheduler.swift+@MainActor final class BackgroundExportScheduler {+    static let identifier: String            // from Info.plist; fatalError if unexpanded, like the App Group key+    init(submitter: @escaping (BGAppRefreshTaskRequest) throws -> Void = BGTaskScheduler.shared.submit)+    func submit()+}+```++**The waiter shape in `awaitExport`.** Each waiter has an id, a threshold, an optional deadline sleeper task, and its continuation, in a main-actor list. Registration happens inside the `withCheckedContinuation` closure and checks `Task.isCancelled` first, resuming `.cancelled` without registering if so. `onCancel` is non-isolated and only hops: `Task { @MainActor in monitor.resolve(id, .cancelled) }`. Every resolver — a qualifying event in `ingest`, the deadline sleeper, `stop()`, the cancel hop — *removes the waiter from the list, then resumes it*, so a waiter resumes exactly once whichever resolver wins. `awaitQuiescence` is not the template: it has no cancellation handling.++Contracts worth stating:++- `run` logs `start` with a pass UUID, the count listed and the count settled, and `end` with the same UUID, the outcome, and the elapsed time, every field `privacy: .public`. There is no reader content to redact. `BackgroundExportLog` is a Core `Logger` under `category:BackgroundExport`; the injectable sink is the test seam.+- `run` calls `release()` on every path after a successful `acquire()`, including cancellation, and never before the wait has returned. It clears the outstanding names only on `.exported`, before `release()`; settled names are cleared before `acquire()`. A `.cancelled` result is reported as `.preempted` when `session.isPreempting`, else `.expired`.+- `acquire()` on the cold path stops the monitor and shuts down the repository on every failing arm, including the throwing open.+- `awaitExport` ignores events with `startDate == nil` or `startDate <= threshold`, and ignores import events. A failed setup event after the threshold is `.failed`: the mirror announced it will not export.+- `SyncMonitor.ingest` updates `lastExportStarted` only on a *successful* export, and only forward.+- `runBackgroundExport()` with a pass already in flight returns that pass's outcome rather than starting another.+- `isBulkOperationInProgress()` is answered by the repository actor, so it is consistent with the flag `reconcileAfterSync` and import set (Q46).++## Data Models++`SyncStatusRecord` gains `lastExportStarted: Date?`. The record is JSON with synthesised `Codable`; a file written before this field decodes with nil, a file written after it is ignored by an older build. `currentVersion` stays 1.++`ExportOwed/` is a new App Group directory beside `PendingCaptures/`, declared on `LibraryConfiguration` like every other path.++## Error Handling++| Condition | Where caught | Outcome | Marker |+|---|---|---|---|+| Marker directory unreadable (protected, permissions) | `pending()` throws | `.skipped(.unavailable)` | untouched |+| Every listed marker older than the last successful export start | `settle` | `.skipped(.alreadyExported)` | cleared |+| Open throws (migration, unverifiable, lock timeout, protected store) | `acquire()` | `.skipped(.unavailable(reason))` | untouched |+| Open cancelled while waiting for the lock | `acquire()` maps `CancellationError` | `.expired` / `.preempted` | untouched |+| Open succeeds without mirroring attached | `acquire()` | `.skipped(.unavailable)` after shutdown | untouched |+| Bulk operation live | `acquire()` | `.skipped(.bulkOperation)` | untouched |+| Export or setup event fails after the threshold | `awaitExport` → `.failed` | `.failed(message)` | untouched |+| No qualifying event before the deadline (nothing pending, CloudKit deferred) | `awaitExport` → `.deadline` | `.timedOut` | untouched |+| Task cancelled by the system | `awaitExport` → `.cancelled`, or the post-`acquire` check | `.expired` | untouched |+| Task cancelled by a foreground bootstrap | same, with `isPreempting` | `.preempted` | untouched |+| Monitor stopped under the wait (teardown) | `awaitExport` → `.stopped` | `.preempted` | untouched |+| Scheduler refuses `submit` | `BackgroundExportScheduler` | logged once per process | n/a |+| Marker write fails in the extension | `finish` | logged; capture completes | absent |++## Testing Strategy++All host tests run under `make test-core` (package) and `make test-quick` (app), on temporary `LibraryConfiguration(rootDirectory:cloudKitContainerID:)` roots. The app tests import Core without `@testable`, so every seam they use is public: `MirroringOpenHooks(makeMirroredContainer:)` (counts constructions, returns a `.none` container over the store, and lets the test hold it weakly to prove release), `SyncMonitor.observe(_:)`, `init(readyRepository:)` with `MockLibraryProvider`, and the model's `mirroringOpenHooks`.++**`ExportOwedMarkerTests`** (Core): missing directory reads empty; `mark` twice yields two entries with distinct names and non-decreasing creation dates; `clear` removes exactly the given names and leaves one marked after the listing (the property behind [2.3](requirements.md#2.3)); an unreadable directory (a file where the directory should be) makes `pending()` throw rather than answer empty; `mark` into an unwritable location throws ([2.5](requirements.md#2.5)'s failing arm — the extension's ignore-and-continue is one `try?` with a log line, reviewed by inspection).++**`SyncMonitorTests`** (Core, existing suite): `ingest` records `lastExportStarted` on a successful export, not on a failed one, and never moves it backwards; `awaitExport` ignores an export whose `startDate` precedes the threshold; resolves `.exported` on a later successful one, `.failed` on a later failed export and on a later failed setup; ignores imports; resolves `.deadline` through the fake clock and sleeper without a real wait; resolves `.cancelled` when the awaiting task is cancelled, and when it was already cancelled before the call; resolves `.stopped` on `stop()`; two waiters with different thresholds resolve independently; a waiter resolves exactly once when an event and a cancellation race.++**`BackgroundExportPassTests`** (Core) with a fake session recording `acquire`/`release` calls and handing back a `SyncMonitor` the test feeds through `observe`, and the injected log sink: no marker → `.skipped(.noMarker)` with no `acquire`; markers all older than a persisted start → `.skipped(.alreadyExported)`, cleared, no `acquire`; a mix settles the old ones and waits on the newest; each `SkipReason` from `acquire` passes through with no `release`; `.cancelled` from `acquire` reports `.expired` or `.preempted` by `isPreempting` with no `release`; `.exported` clears the outstanding names and not a name marked after the listing; `.failed`, `.timedOut`, `.expired` leave the marker and still call `release`; cancellation before the wait (after `acquire`) releases without waiting; `release` is called exactly once on every acquired path; the log lines carry one pass identifier from start to end.++**`AppLibraryModelTests`** (app): cold pass opens and releases (`repository` stays nil, `state` stays `.loading`, one construction counted, the weakly held container is nil afterwards, the monitor is stopped, `onArrivals` was never set); cold pass on a root that cannot mirror skips as unavailable after shutting down; cold pass on a refused store (an unreadable marker file makes certification throw) skips as unavailable with the store bytes unchanged ([1.9](requirements.md#1.9)); a cold pass leaves the record counts unchanged ([1.11](requirements.md#1.11)); resident pass reuses (`bootstrap()` first, then `runBackgroundExport()` constructs nothing); a resident pass with `MockLibraryProvider.isBulkOperationInProgress = true` skips; arrivals during a resident pass are deferred and reconciled at the next `drainAndReconcile()`; `bootstrap()` during a cold pass cancels it (`.preempted`) and reaches `.ready` with the concurrent-container high-water mark at one; a pass during a bootstrap whose factory is blocked until signalled skips as opening, high-water mark one; two concurrent `runBackgroundExport()` calls share one outcome, and cancelling one caller's task expires the pass for both; foreground clearing after `bootstrap()` settles a marker older than the persisted start at once, clears a newer one when a later export is observed, and leaves it when the observed export started earlier; `teardownRepository()` cancels the arm.++**`BackgroundExportSchedulerTests`** (app, iOS): the submitter is called once per `submit()`; a throwing submitter logs once across many calls ([3.3](requirements.md#3.3)).++**UI test** (`make test-ui`, Development scheme): the Debug disclosure shows `settings-background-export-run`; tapping it on the UI-test root produces `settings-background-export-result` reading the no-marker outcome ([4.2](requirements.md#4.2)).++**`verify-identity.sh`**: no self-test harness exists. The implementation task removes the permitted-identifiers element and the `fetch` mode in turn and confirms `make verify-identity` fails with the intended message each time, then restores them.++**Not host-testable, covered by the runbook** (`runbook.md`, written with the tasks): a real grant on a cold launch, the simulated launch on a resident app suspended after an extension capture (must end `.exported`, Q29), simulated expiration, Background App Refresh disabled in Settings, the marker's protection class on device, the second-install arrival of [1.1](requirements.md#1.1), and a watch for early settlement on the two-device pass. `Development` first, `Personal` last after a container download; every device run needs approval at the moment it runs.++Property-based testing is not used: the one universal property (clear never removes a name absent from its snapshot) is fully covered by the example tests above.++## Documentation++- `specs/cloudkit-mirroring/prerequisites.md` lines 10–19: the "superseded by T-2052" sentence becomes a pointer to this spec as the closing of that gap.+- `CLAUDE.md` build-tooling section: note that `Development` builds carry the Settings trigger and that device verification of this feature is a device run under the existing rule.
specs/background-export/prerequisites.md Added +16 / -0
diff --git a/specs/background-export/prerequisites.md b/specs/background-export/prerequisites.mdnew file mode 100644index 0000000..bad4e1b--- /dev/null+++ b/specs/background-export/prerequisites.md@@ -0,0 +1,16 @@+# Prerequisites for Background Export++These tasks must be completed by the user before or during implementation.++## Before Starting++- [ ] Nothing. No new entitlement, capability toggle, or provisioning-profile change is needed: app-refresh tasks are declared in `Info.plist` alone (`UIBackgroundModes` and `BGTaskSchedulerPermittedIdentifiers`, task 5), and both App IDs already carry Background Modes from `specs/cloudkit-mirroring/prerequisites.md`.++## Before Testing++Every item below is a physical-device run under `CLAUDE.md` and needs your explicit approval at the moment it is run. Blocks task 12's runbook.++- [ ] **Two devices signed into the same iCloud account**, both with the `Development` build installed, so the second-install arrival of Req 1.1 can be observed. The `Development` container is separate from the real library and is the one every runbook step runs against first.+- [ ] **Background App Refresh enabled** for the `Development` install on the phone (Settings → General → Background App Refresh), and Low Power Mode off, for the steps that expect a real grant. One step turns it off deliberately to observe the refusal line.+- [ ] **Xcode attached to the phone** for the simulated-launch and simulated-expiration steps; they run through the debugger console and cannot be driven any other way.+- [ ] **A container download before the `Personal` step**: `xcrun devicectl device copy from --domain-type appGroupDataContainer --domain-identifier group.me.nore.ig.Asterism --source Library …` as `specs/cloudkit-mirroring/runbook-log.md` records. The `Personal` observation is last, optional, and only after every step has passed on `Development`.
specs/background-export/requirements.md Added +88 / -0
diff --git a/specs/background-export/requirements.md b/specs/background-export/requirements.mdnew file mode 100644index 0000000..b09cfb5--- /dev/null+++ b/specs/background-export/requirements.md@@ -0,0 +1,88 @@+# Requirements: Background Export++**Ticket:** T-2052++## Introduction++A capture made through the share extension reaches CloudKit only when the app is next brought to the foreground: the extension writes through a store with mirroring off, and only the app owns a mirrored container (cloudkit-mirroring Req 5.1, 5.4). The latency is visible — share on the phone, and nothing appears on another device until the phone's app is opened. This feature keeps a request for background execution pending whenever the app is not in the foreground and, when a grant arrives and the extension has left something to export, opens the library briefly, lets the mirror export, and releases it. The extension gains one obligation: to leave a marker saying an export is owed. It still never mirrors.++Reference: `specs/cloudkit-mirroring/` (Req 5.1, 5.4, 6.3, 8.1; Q24 single mirrored container; Q43 shutdown contract; Q46 bulk-operation exclusion; `prerequisites.md` lines 10–19, which this feature supersedes), `specs/pending-capture-queue/` (whose drain is deliberately not run here).++## Non-Goals++- Mirroring from the extension, or the extension attaching a CloudKit container in any form. The memory budget and the in-process 134422 collision (cloudkit-mirroring Req 5.1, 6.3, Q24) stand.+- Waking or launching the app from the extension. iOS offers no such path; the marker only steers a grant the app already holds.+- Any latency bound. iOS decides whether and when a grant arrives, and grants none while Background App Refresh is off, in Low Power Mode, or after the reader force-quits the app until it is next launched. The feature turns "until the app is next opened" into "at the system's discretion", not into a promise.+- Draining the pending-capture queue in the background. Preserved records stay on disk and drain at the next foreground activation, as today (Q3).+- Reconciliation, launch reconcile, snapshot refresh, suggestion or extraction sweeps in the background. The mirror imports as well as exports while the library is open, and whatever arrives during a pass is reconciled at the next foreground activation, exactly as arrivals are today.+- New reader-facing UI. The sync status record that Settings already shows keeps updating as the mirror reports events (cloudkit-mirroring Req 8.1); nothing is added to it, and the only new control is the Development-only trigger (Q5).+- Handling CloudKit's silent push in the background for the import direction. Unchanged by this feature.+- Proving that a specific row was exported. The mirror reports export activity, not which records it carried; completion is judged from activity, and the marker errs on the side of surviving.+- Schema, archive format, or CloudKit record changes.++---++### 1. Extension Captures Export Without a Foreground Open++**User Story:** As the reader, I want a page I shared from my phone to show up on my other devices without opening the app on the phone, so that capture is a one-gesture act.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHERE iOS grants the app background execution after the extension has committed a capture, and iCloud is reachable, that capture SHALL appear on a second install signed into the same account without the reader bringing the app to the foreground on the first.+2. <a name="1.2"></a>WHEN a grant arrives and an export is owed ([2.1](#2.1)), the pass SHALL make the library available to the mirror for the duration needed to export, and SHALL end as soon as it has observed export activity that began after the pass found the marker and finished without error.+3. <a name="1.3"></a>Every pass SHALL end no later than a bounded budget whether or not the mirror reports anything, with the marker intact: no export activity ends it at the budget, and an export or setup that fails — a mirror with no iCloud account, for one — ends it as soon as the failure is reported.+4. <a name="1.4"></a>IF the system signals that the grant is about to expire, THEN the pass SHALL release the library and report completion before the deadline. No committed change SHALL be lost or duplicated by an interrupted pass; the changes still pending remain for a later pass or the next foreground open.+5. <a name="1.5"></a>WHEN a pass ends for any reason in a process with no scene connected, it SHALL have released the library — no store file in the App Group container held open — before it reports completion, so a process suspended after the pass holds no lock on the shared container.+6. <a name="1.6"></a>WHERE the app is resident with a live library when the grant arrives, the pass SHALL reuse that library, and SHALL end on the same terms as [1.2](#1.2) and [1.3](#1.3).+7. <a name="1.7"></a>IF the reader brings the app to the foreground while a pass holds the library, THEN the app SHALL reach its ready state and the reader SHALL see no library-unavailable state caused by the pass.+8. <a name="1.8"></a>At no moment SHALL two containers with mirroring on be open over the same store (cloudkit-mirroring Q24), whether the pass and the foreground overlap, a pass overlaps a pass, or a pass overlaps a foreground open already under way.+9. <a name="1.9"></a>WHERE the library cannot be opened — a migration is pending, the store is unverifiable, another process holds the cross-process lock, or the store is protected because the device has not been unlocked since restart — the pass SHALL end without modifying the store and without presenting anything, and the marker SHALL survive. The condition SHALL be reported at the next foreground open exactly as it is today.+10. <a name="1.10"></a>WHERE a bulk operation (import or reconciliation, cloudkit-mirroring Q46) is in progress on a live library when the grant arrives, the pass SHALL neither start nor interrupt it. The mirror exports on its own as the bulk operation saves.+11. <a name="1.11"></a>A pass SHALL write nothing to the library beyond what every foreground open already performs — certification, its repairs, and work-type seeding — and the mirror's own bookkeeping. It SHALL create, edit, or delete no capture, teaching, or setting.++---++### 2. The Extension Says When an Export Is Owed++**User Story:** As the reader, I want the app to spend a background grant only when there is something to send, so that background refresh is not wasted on opening an unchanged library.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN the extension commits a capture, it SHALL leave a marker in the App Group container that the app can read without opening the library. Each new commit SHALL produce a marker distinguishable from every earlier one, so the app can tell whether a capture landed after it last looked.+2. <a name="2.2"></a>WHEN a grant arrives and no marker is present, the pass SHALL end without opening the library.+3. <a name="2.3"></a>The app SHALL clear a marker only when the marker it clears is the one it found before the export activity it observed began — a marker left by a capture committed after that point SHALL survive, in a background pass and in the foreground alike. A marker whose export was interrupted or never observed ([1.3](#1.3), [1.4](#1.4)) SHALL survive to the next pass and across launches.+4. <a name="2.4"></a>WHEN the app is in the foreground with a live mirror and observes export activity that began after it found a marker, it SHALL clear that marker on the terms of [2.3](#2.3), so a grant after ordinary use does not open the library for nothing.+5. <a name="2.5"></a>IF writing the marker fails, THEN the capture SHALL still commit and the extension SHALL report the capture as it does today. The cost of a lost marker is the latency this feature removes, never a lost capture.+6. <a name="2.6"></a>IF the marker cannot be read because the App Group container is protected before first unlock, THEN the pass SHALL end as "library unavailable" ([1.9](#1.9)), not as "no marker".+7. <a name="2.7"></a>The extension SHALL continue to open the store with mirroring off (cloudkit-mirroring Req 5.1) and SHALL still capture on a device whose library is still arriving (Req 6.3).++---++### 3. A Grant Is Always Requested++**User Story:** As the reader, I want the app to keep asking for background time without my involvement, so that the opportunity exists after every use of the phone.++**Acceptance Criteria:**++1. <a name="3.1"></a>Whenever the app is not in the foreground, a background request SHALL be pending, and at most one. The app SHALL resubmit after each pass and each time it leaves the foreground.+2. <a name="3.2"></a>The request SHALL NOT require the device to be on external power.+3. <a name="3.3"></a>WHERE the system refuses the request — Background App Refresh off for the app, or any other refusal — the app SHALL log the refusal once per launch ([4.1](#4.1)), the feature SHALL be inert, and the foreground app SHALL be unaffected. Captures reach CloudKit at the next foreground open, as today.+4. <a name="3.4"></a>Each configuration (`Development`, `Personal`) SHALL declare its own background execution permissions and run the pass against its own store and container.+5. <a name="3.5"></a>The pass SHALL run from a cold background launch with no scene connected, as well as when the app was resident ([1.6](#1.6)).++---++### 4. Passes Are Observable++**User Story:** As the developer, I want every pass to leave a record of what it did, so that a capture that did not arrive can be traced to a skipped, expired, or failed pass rather than guessed at.++**Acceptance Criteria:**++1. <a name="4.1"></a>Each pass SHALL log, under `subsystem:me.nore.ig.Asterism category:BackgroundExport`, its start with a pass identifier and the marker it found, and its outcome with the same identifier and its duration — exported, skipped (no marker), skipped (library unavailable, with the reason), skipped (bulk operation in progress), timed out (no export observed), expired, or failed (with the error). Every field SHALL be readable in Console for both configurations; the pass carries no reader content.+2. <a name="4.2"></a>`Development` builds SHALL offer a control in Settings that runs the same pass the scheduler would, on demand, and shows its outcome inline. `Personal` builds SHALL NOT show the control.++---++## Verification++Background grants cannot be exercised on the simulator in any meaningful way, and Xcode's simulated task launch needs a debugger attached to a running process, so it exercises the resident path ([1.6](#1.6)) but not the cold launch ([3.5](#3.5)). The pass logic is proven on the host through the seams the design names. The resident path and expiration are verified on a device with the simulated launch; the cold path is observed in the field through Console after a real grant. Verification uses the `Development` configuration first — it mirrors to its own container and cannot touch the real library — with a final `Personal` observation only once the pass has behaved there. Every device run needs the user's explicit approval at the moment it happens under `CLAUDE.md`, and a `Personal` run is preceded by a container download. The spec carries a runbook; the runbook is not an acceptance criterion.
specs/background-export/runbook.md Added +353 / -0
diff --git a/specs/background-export/runbook.md b/specs/background-export/runbook.mdnew file mode 100644index 0000000..5aa9ee0--- /dev/null+++ b/specs/background-export/runbook.md@@ -0,0 +1,353 @@+# Device Runbook: Background Export++T-2052. Requirements: [`requirements.md`](requirements.md) · Design:+[`design.md`](design.md) · Decisions: [`decision_log.md`](decision_log.md)++Background grants cannot be exercised on the simulator, so the parts of this+feature that host tests cannot reach are checked here: a real grant on a cold+launch, the simulated launch on a resident app, simulated expiration, the+refusal path, the marker's protection class, and the two-device arrival of+[1.1](requirements.md#1.1). The runbook is not an acceptance criterion; it is+how the field evidence is gathered and recorded.++## The rule that governs every step++**Every step below is a physical-device run under `CLAUDE.md` and needs the+user's explicit approval at the moment of running.** This file existing, a task+saying to run it, or an approval given for an earlier step is not approval for+the next one. The steps are the owner's to run, not an agent's to work through.++Order is not a suggestion:++1. **`Development` first.** It installs as a separate app with a separate App+   Group and its own CloudKit container (`iCloud.me.nore.ig.Asterism.dev`), so+   nothing it does can touch the real library. Every step 1–7 runs here.+2. **`Personal` last** (step 8), only after every `Development` step has passed,+   and only after a container download of the real library:++   ```+   xcrun devicectl device copy from \+     --device <UDID> \+     --domain-type appGroupDataContainer \+     --domain-identifier group.me.nore.ig.Asterism \+     --source Library \+     --destination ~/asterism-personal-<yyyy-mm-dd>/+   ```++   as `specs/cloudkit-mirroring/runbook-log.md` records — Xcode's own container+   download does not include the App Group. Add a second copy with+   `--source ExportOwed` if the marker directory is wanted alongside the store.+   The `Personal` step is **observation only**: no simulated launch, no+   simulated expiration.++## Prerequisites++From [`prerequisites.md`](prerequisites.md), all of them before step 1:++- Two devices signed into the same iCloud account, both with the `Development`+  build installed (`make install`), so step 7 can observe a second install.+- Background App Refresh on for the `Development` install (Settings → General →+  Background App Refresh) and Low Power Mode off, for the steps that wait for a+  real grant. Step 5 turns it off deliberately.+- Xcode attached to the phone for steps 2, 3 and 6: the simulated launch, the+  simulated expiration and the protection-class read all go through the+  debugger console and cannot be driven any other way.+- No new entitlement or profile change is needed.++## Reading the log++Console.app, phone selected, filter:++```+subsystem:me.nore.ig.Asterism category:BackgroundExport+```++Both configurations log under that **same** subsystem literal — the `Logger` is+constructed with it directly — so tell the two builds apart by **process**+(`Asterism` from the `me.nore.ig.Asterism.dev` bundle versus the+`me.nore.ig.Asterism` one), not by the filter. A pass carries no reader content,+so every field is public and readable in both builds.++Every pass emits exactly two lines, paired by the same identifier:++```+Background export <UUID> started: <n> marker(s) found, <m> already exported+Background export <UUID> ended: <outcome> after <s.sss> s+```++`n` is what the pass listed in `ExportOwed/`; `m` is how many of those the+persisted `lastExportStarted` settled at listing time, with no library open.+The end line's outcome is one of:++| Outcome text | Means |+|---|---|+| `exported` | An export that started after the newest marker finished without error; the listed markers were cleared |+| `skipped (no marker)` | Nothing owed; the library was never opened |+| `skipped (already exported)` | Every listed marker was older than the recorded export start; they were cleared without opening anything |+| `skipped (library unavailable: <reason>)` | The marker directory or the store could not be read or opened; the marker survives |+| `skipped (bulk operation in progress)` | An import or reconciliation is running and exports on its own |+| `timed out` | The 20 s budget ran out with no qualifying export; the marker survives |+| `expired` | The system cancelled the grant |+| `preempted` | A foreground open cancelled the pass |+| `failed: <message>` | The mirror reported a failed export or setup after the threshold; the marker survives |++The refusal path logs at error level, at most once per launch:++```+Background export refresh request refused: <error>. Captures will reach CloudKit at the next foreground open.+```++Task identifiers, for the debugger commands:++| Configuration | Identifier |+|---|---|+| `Development` | `me.nore.ig.Asterism.dev.backgroundExport` |+| `Personal` | `me.nore.ig.Asterism.backgroundExport` |++---++## Step 1 — Development trigger, and foreground clearing++The cheapest check that the build is wired at all, and the one that exercises+[2.4](requirements.md#2.4) on the way.++**Setup.** `Development` installed and opened once so the library is ready.++**Action.**++1. Settings → the collapsed **Debug** disclosure at the end of the screen →+   **Run background export**. With nothing captured since the last clearing, the+   row should report `Last pass: skipped (no marker)`.+2. Share a page through the share extension. Do **not** open the app.+3. Open the app (foreground, which arms the clearing) and give the mirror a few+   seconds.+4. Tap **Run background export** again.++**Observe.** Console shows a start/end pair per tap. Tap 1 ends+`skipped (no marker)`. Tap 4 ends `skipped (no marker)` or+`skipped (already exported)` — either is the foreground having settled the+marker the capture left.++**Pass.** The row shows the same words the Console line does, and tap 4 does not+report a marker still outstanding minutes after the foreground mirror exported.++**Fail.** A crash on the first tap means the task identifier did not expand at+build time (`make verify-identity`). Tap 4 reporting `1 marker(s) found, 0+already exported` and ending `exported` is not a failure by itself — it means+the foreground arm had not observed the export yet — but if it repeats every+time, foreground clearing is not working.++---++## Step 2 — Resident path, simulated launch (Req 1.6, Q29)++This is the step the design most depends on: a suspended app receives the+extension's cross-process remote-change notification when it resumes, and the+resident mirror then exports without anyone tearing anything down.++**Setup.** Run the `Development` scheme from Xcode onto the phone, wait for the+library to be ready, then leave the app with the home gesture so the process is+backgrounded and suspended (`scenePhase == .background`, which submits a+request). Leave the debugger attached. Share a page through the share extension.+**Do not open the app again.**++**Action.** In Xcode, pause the process (the pause button in the debug bar), and+in the debugger console:++```+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"me.nore.ig.Asterism.dev.backgroundExport"]+```++then `continue`.++**Observe.** In Console, one pass whose start line reports at least+`1 marker(s) found`, and whose end line reads `exported`. The app must not have+been brought to the foreground at any point between the share and that line.++**Pass.** `exported`, with no foreground activation.++**Fail.** `timed out` here is the case Q29 names: the resident mirror did not+export on resume, and the only in-spec fallback (tear down and reopen)+contradicts [1.6](requirements.md#1.6). **Do not patch it** — record the pass+identifiers and both Console lines here, and send the resident row back to+design. `skipped (library unavailable: …)` instead means the model was not in+`.ready` when the grant landed; re-run with the library confirmed ready before+backgrounding.++---++## Step 3 — Simulated expiration (Req 1.4)++**Setup.** As step 2: resident, suspended, debugger attached, one fresh capture+from the extension so a marker is outstanding.++**Action.** Fire the simulated launch as in step 2, `continue`, then pause again+within a second or two and run:++```+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"me.nore.ig.Asterism.dev.backgroundExport"]+```++then `continue`.++This is a race against the pass's own wait, which lasts up to 20 s. If the end+line already reads `exported` the expiration landed too late — capture again and+retry. If it reads `failed: …` the mirror reported an error before the+expiration landed; retry with a working network.++**Observe.** The end line reads `expired`, and its elapsed time is well under+the 20 s budget.++**Pass.** `expired`, **and** the marker survived: tap **Run background export**+from Settings afterwards and confirm the start line still reports+`1 marker(s) found` (whether it then settles it or exports it). A+`skipped (no marker)` there means the expired pass cleared a marker it should+not have.++---++## Step 4 — Cold launch on a real grant (Req 3.5, 1.5)++**Setup.** With the `Development` app having been backgrounded at least once+this launch (so a request is pending), **stop the process from Xcode** — the+stop button, or `xcrun devicectl device process terminate`. Do **not** swipe-kill+it from the app switcher: a swipe-kill stops iOS granting the app background+time until it is next launched by hand, and the step then proves nothing.++Then share a page through the share extension and leave the phone alone. Use it+normally; grants arrive on the system's schedule and can take minutes to hours.++**Observe.** In Console, a start/end pair with a **new** pass identifier, from a+process that was never brought to the foreground — the app must not appear on+screen, and no scene-related activity precedes the pass. The start line reports+the marker; the end line should read `exported`.++**Pass.** `exported` from a launch with no foreground open, and the capture is on+the second device (step 7 covers that half deliberately). Nothing in the App+Group container is left held open: opening the app afterwards reaches its ready+state normally, with no lock timeout in `category:LibraryRepository`.++**Fail.** `skipped (library unavailable: …)` — read the reason; a protected store+before first unlock is expected behaviour ([1.9](requirements.md#1.9)) and should+be recorded as such rather than treated as a defect.++---++## Step 5 — Background App Refresh off (Req 3.3)++**Setup.** Settings → General → Background App Refresh → turn it **off** for the+`Development` install.++**Action.** Launch the app, use it, background it, foreground it, background it+again — several submit sites in one launch. Then relaunch the app and repeat.++**Observe.** Console shows the refusal line **exactly once per launch** (the+scheduler's flag is process-scoped), never once per backgrounding.++**Pass.** One refusal line per launch, and the app is otherwise unaffected:+opening it in the foreground still exports — share through the extension, open+the app, and confirm the capture reaches the second device as it did before this+feature existed.++**Afterwards.** Turn Background App Refresh back on before step 7.++---++## Step 6 — Marker protection class on device (Q18, Req 2.6)++The marker directory and each file in it are created with+`FileProtectionType.completeUntilFirstUserAuthentication`, because a refresh+grant usually arrives on a locked phone and the spool's `completeUnlessOpen`+would make the marker unreadable exactly then. A file takes its creator's+default class, not its directory's, so both are worth reading.++**Direct read.** With the `Development` app running from Xcode and paused, in+the debugger console:++```+po FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.me.nore.ig.Asterism.dev")!.appending(path: "ExportOwed").path(percentEncoded: false)+po try FileManager.default.attributesOfItem(atPath: "<the path printed above>")[.protectionKey]+po try FileManager.default.contentsOfDirectory(atPath: "<the path printed above>")+po try FileManager.default.attributesOfItem(atPath: "<path>/<one of the UUID names>")[.protectionKey]+```++Capture through the extension first so there is at least one file to read.++**Expect.** `NSFileProtectionCompleteUntilFirstUserAuthentication` for the+directory **and** for the file. A file reading `NSFileProtectionComplete` or+`NSFileProtectionCompleteUnlessOpen` is the defect this step exists to catch.++**Behavioural confirmation.** Share through the extension, lock the phone, and+leave it locked until a grant lands. The pass's start line must report the+marker (`1 marker(s) found`) rather than ending+`skipped (library unavailable: …)`: a marker the pass cannot read on a locked+but once-unlocked phone is the wrong protection class, whatever the attribute+says.++Note that a container download will not settle this — protection classes do not+survive the copy to the Mac.++---++## Step 7 — Second-install arrival, and the early-settlement watch (Req 1.1, Q31)++**Setup.** Both devices on `Development`, same iCloud account, Background App+Refresh on, network available. Device A is the phone with the share extension;+device B is the second install. Open the app on B once so it is up to date, then+leave both alone.++**Action.** On device A, share a page through the share extension. **Do not open+the app on A.** Wait for a grant.++**Observe.** On A, a pass ending `exported`. Then open the app on B: the capture+is there.++**Pass.** The capture appears on B without A's app ever having been foregrounded.++**The early-settlement watch (Q31).** The settlement rule is documented as+best-effort: an export the mirror enqueued *before* the commit can start after+the marker and succeed without carrying it. What that looks like here is a pass+whose end line reads `exported` or `skipped (already exported)` while the+capture is still missing on B several minutes later. It is bounded — the+capture goes with the next export, at the next foreground open on A — so it is+not a failure of the step, but **record it**: the pass identifier, the start+line's counts, the share time and the time the capture actually reached B. If it+is common rather than rare, Q31's cost estimate is wrong and the rule needs+revisiting.++---++## Step 8 — Personal observation (last)++Only after steps 1–7 have passed on `Development`, with fresh approval at the+moment, and **only after the container download** at the top of this file.++**Action.** `make install-release`, open the app once, background it, share a+page through the share extension, leave the phone alone.++**Observe.** Console, filtered as above, on the `me.nore.ig.Asterism` process: a+pass ending `exported`, and the capture on the other `Personal` install without+the app being foregrounded on the phone.++**Not in this step.** No simulated launch, no simulated expiration, no+Background App Refresh toggling, no debugger reads of the container. Those were+done on `Development`; here the only question is whether the pass behaves the+same against the real library.++---++## Results++Fill in as the steps are run. Record the pass identifiers in Notes for anything+that did not pass first time.++| # | Step | Date | Configuration | Outcome | Notes |+|---|------|------|---------------|---------|-------|+| 1 | Development trigger, foreground clearing | | | | |+| 2 | Resident path, simulated launch | | | | |+| 3 | Simulated expiration | | | | |+| 4 | Cold launch on a real grant | | | | |+| 5 | Background App Refresh off | | | | |+| 6 | Marker protection class | | | | |+| 7 | Second-install arrival, early-settlement watch | | | | |+| 8 | Personal observation | | | | |
specs/background-export/tasks.md Added +168 / -0
diff --git a/specs/background-export/tasks.md b/specs/background-export/tasks.mdnew file mode 100644index 0000000..25498cd--- /dev/null+++ b/specs/background-export/tasks.md@@ -0,0 +1,168 @@+---+references:+    - specs/background-export/requirements.md+    - specs/background-export/design.md+    - specs/background-export/decision_log.md+---+# Background Export++## Core++- [x] 1. Marker directory <!-- id:3rr2is1 -->+  - Stream: 1+  - [x] 1.1. Write ExportOwedMarkerTests (red) <!-- id:3rr2is2 -->+    - New suite. Cases: missing directory reads empty; mark twice → two entries, distinct names, non-decreasing createdAt; clear(names) removes exactly those and leaves an entry marked after the listing; a regular file at the directory path makes pending() throw, not answer []; mark() into an unwritable location throws.+    - Do not assert the protection class on the host (vacuous on macOS) — that is the runbook's.+    - Stream: 1+    - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6)+    - References: Packages/AsterismCore/Tests/AsterismCoreTests/ExportOwedMarkerTests.swift+  - [x] 1.2. Implement ExportOwedMarker and LibraryConfiguration.exportOwedURL <!-- id:3rr2is3 -->+    - exportOwedURL = <rootDirectory>/ExportOwed, declared beside pendingCapturesURL.+    - Entry(name, createdAt) from URLResourceKey.creationDateKey. mark(): create the directory if missing and the empty <UUID> file, both with FileProtectionType.completeUntilFirstUserAuthentication set at creation (files do not inherit the directory's class). pending(): missing directory → []; any other listing error rethrows. clear(): unlink by name, ENOENT ignored (design §The marker, Decision 3, Q18).+    - Blocked-by: 3rr2is2 (Write ExportOwedMarkerTests (red))+    - Stream: 1+    - Requirements: [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [2.6](requirements.md#2.6)+    - References: Packages/AsterismCore/Sources/AsterismCore/ExportOwedMarker.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift++- [x] 2. Sync monitor export rule <!-- id:3rr2is4 -->+  - Stream: 1+  - [x] 2.1. Extend SyncMonitorTests for startDate, lastExportStarted and awaitExport (red) <!-- id:3rr2is5 -->+    - ingest/observe records lastExportStarted only on a successful export and never moves it backwards.+    - awaitExport: ignores an export whose startDate ≤ threshold or is nil; ignores imports; .exported on a later successful export; .failed on a later failed export and on a later failed setup; .deadline via the fake clock + sleeper with no real wait; .cancelled when the awaiting task is cancelled, and when it was already cancelled before the call; .stopped on stop(); two waiters with different thresholds resolve independently; a waiter resolves exactly once when an event and a cancellation race.+    - Stream: 1+    - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [2.3](requirements.md#2.3)+    - References: Packages/AsterismCore/Tests/AsterismCoreTests/SyncMonitorTests.swift+  - [x] 2.2. Implement SyncEvent.startDate, SyncStatusRecord.lastExportStarted, awaitExport and observe <!-- id:3rr2is6 -->+    - SyncEvent.startDate: Date? (init default nil); the notification handler fills it from Event.startDate. SyncStatusRecord.lastExportStarted: Date?, currentVersion stays 1.+    - Waiter shape (design §Components): id-keyed main-actor list; register inside withCheckedContinuation after a Task.isCancelled check; onCancel only hops Task { @MainActor in resolve(id, .cancelled) }; every resolver removes-then-resumes; one sleeper task per optional deadline, cancelled on resolve; stop() resolves export waiters .stopped. awaitQuiescence is not the template.+    - observe(_:) is the public spelling of ingest (Q19).+    - Blocked-by: 3rr2is5 (Extend SyncMonitorTests for startDate, lastExportStarted and awaitExport (red))+    - Stream: 1+    - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [2.3](requirements.md#2.3)+    - References: Packages/AsterismCore/Sources/AsterismCore/SyncMonitor.swift, Packages/AsterismCore/Sources/AsterismCore/SyncStatus.swift++- [x] 3. Add isBulkOperationInProgress() to LibraryProviding and LibraryRepository <!-- id:3rr2is7 -->+  - Protocol requirement with a default false in the extension (like shutdown()). LibraryRepository answers its bulkOperationInProgress flag from the actor. Interface-only; the pass tests (4.1) and the model tests (7.1) exercise it through fakes.+  - Stream: 1+  - Requirements: [1.10](requirements.md#1.10)+  - References: Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift++- [x] 4. Background export pass <!-- id:3rr2is8 -->+  - Stream: 1+  - [x] 4.1. Write BackgroundExportPassTests with a fake session (red) <!-- id:3rr2is9 -->+    - Fake BackgroundExportSession records acquire/release/isPreempting and hands back a real SyncMonitor over a temp status file that the test feeds through observe; inject the log sink and a fake clock.+    - Cases: no marker → .skipped(.noMarker), no acquire; all markers older than a persisted lastExportStarted → .skipped(.alreadyExported), cleared, no acquire; a mix settles the old ones and waits with threshold = newest createdAt; each SkipReason from acquire passes through with no release; .cancelled from acquire → .expired or .preempted by isPreempting, no release; .exported clears the outstanding names and not a name marked after the listing; .failed/.timedOut/.expired leave the marker and still release; cancellation after acquire releases without waiting; release exactly once on every acquired path; start and end log lines share one pass id and end carries the outcome and elapsed time.+    - Blocked-by: 3rr2is3 (Implement ExportOwedMarker and LibraryConfiguration.exportOwedURL), 3rr2is6 (Implement SyncEvent.startDate, SyncStatusRecord.lastExportStarted, awaitExport and observe)+    - Stream: 1+    - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.10](requirements.md#1.10), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [4.1](requirements.md#4.1)+    - References: Packages/AsterismCore/Tests/AsterismCoreTests/BackgroundExportPassTests.swift+  - [x] 4.2. Implement BackgroundExportPass, its outcomes, bounds, settle and log <!-- id:3rr2isa -->+    - Public types per design §Components: BackgroundExportOutcome (exported, skipped(SkipReason: noMarker/alreadyExported/unavailable(String)/bulkOperation), timedOut, expired, preempted, failed(String)), BackgroundExportBounds.passBudget = 20 s, BackgroundExportAcquisition, BackgroundExportSession, BackgroundExportPass(marker:statusURL:clock:budget:log:), static settle(_:against:marker:).+    - BackgroundExportLog: Logger(subsystem: "me.nore.ig.Asterism", category: "BackgroundExport"); every field privacy: .public; the sink parameter defaults to it.+    - run order: t0 → pending() (throw → .skipped(.unavailable)) → settle → acquire → Task.isCancelled check → awaitExport(startedAfter: newest, deadline: t0 + budget) → clear on .exported → release → outcome. Status is read with SyncStatusFile.read (internal to Core).+    - Blocked-by: 3rr2is9 (Write BackgroundExportPassTests with a fake session (red)), session, session, session, session, session, session, session, session, session, session, session, session, session, session+    - Stream: 1+    - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.10](requirements.md#1.10), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [4.1](requirements.md#4.1)+    - References: Packages/AsterismCore/Sources/AsterismCore/BackgroundExportPass.swift++## App++- [x] 5. Declare the background task in both configurations and teach the identity lint <!-- id:3rr2isb -->+  - pbxproj, project level, both configurations: ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER = "$(ASTERISM_IDENTITY).backgroundExport" (beside the App Group and container derivations, lines ~799-803 and ~868-872).+  - Info.plist (app only): AsterismBackgroundExportTaskIdentifier → $(ASTERISM_BACKGROUND_EXPORT_TASK_IDENTIFIER); BGTaskSchedulerPermittedIdentifiers → one element, the same reference; UIBackgroundModes gains fetch. The extension plist gets nothing.+  - verify-identity.sh: pinned value in check_project_level_settings; in check_target_info_plist's app arm, check_plist_reference on the scalar key, a check that BGTaskSchedulerPermittedIdentifiers.0 is the same reference and the array has one element (plutil -extract on the array returns a count, so address the element), and that UIBackgroundModes contains fetch; the extension arm asserts both keys absent; add the composed literal to the check-5 sweep.+  - There is no lint self-test: remove the array element, then the fetch mode, in turn, confirm make verify-identity fails with the intended message each time, restore, and record the two messages in the task's commit body.+  - Stream: 2+  - Requirements: [3.4](requirements.md#3.4)+  - References: Asterism/Asterism.xcodeproj/project.pbxproj, Asterism/Asterism/Info.plist, scripts/verify-identity.sh++- [x] 6. Refresh request scheduler (iOS) <!-- id:3rr2isc -->+  - Stream: 2+  - [x] 6.1. Write BackgroundExportSchedulerTests (red) <!-- id:3rr2isd -->+    - iOS-only test file (#if os(iOS)). Inject the submitter closure: submit() calls it once with a BGAppRefreshTaskRequest carrying the identifier and no earliestBeginDate; a throwing submitter is logged once across many submit() calls and never throws out; the identifier reads AsterismBackgroundExportTaskIdentifier from the bundle.+    - Blocked-by: 3rr2isb (Declare the background task in both configurations and teach the identity lint)+    - Stream: 2+    - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3)+    - References: Asterism/AsterismTests/BackgroundExportSchedulerTests.swift+  - [x] 6.2. Implement BackgroundExportScheduler <!-- id:3rr2ise -->+    - Whole file under #if os(iOS); import BackgroundTasks here only. @MainActor final class; static let identifier read from Info.plist with a fatalError on a missing or unexpanded value, matching declaredAppGroupIdentifier(); init(submitter:) defaulting to BGTaskScheduler.shared.submit (not deprecated on the iOS 26 SDK — Q-note in Decision 2); submit() is synchronous, builds BGAppRefreshTaskRequest(identifier:) with no earliestBeginDate, logs a throw once per process under category BackgroundExport.+    - Blocked-by: 3rr2isd (Write BackgroundExportSchedulerTests (red))+    - Stream: 2+    - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3)+    - References: Asterism/Asterism/Support/BackgroundExportScheduler.swift++- [x] 7. Library session on AppLibraryModel <!-- id:3rr2isf -->+  - Stream: 1+  - [x] 7.1. Write AppLibraryModelTests for the pass, pre-emption and reentrancy (red) <!-- id:3rr2isg -->+    - Only public Core seams: LibraryConfiguration(rootDirectory:cloudKitContainerID:) with a test id, MirroringOpenHooks(makeMirroredContainer:) that counts constructions, returns a .none container over the store and lets the test hold it weakly, SyncMonitor.observe, init(readyRepository:) with MockLibraryProvider.+    - Cases: cold pass opens and releases (repository nil, state .loading, one construction, weak container nil afterwards, monitor stopped, onArrivals never set); a root that cannot mirror (no container id) skips unavailable after shutting down; a refused store (unreadable readiness marker) skips unavailable with store bytes unchanged (1.9); record counts unchanged after a cold pass (1.11); resident pass after bootstrap() constructs nothing; MockLibraryProvider.isBulkOperationInProgress = true → .skipped(.bulkOperation); arrivals during a resident pass are deferred and reconciled at the next drainAndReconcile(); bootstrap() during a cold pass → .preempted and .ready with the concurrent-container high-water mark at one; a pass during a bootstrap whose factory blocks until signalled skips as opening, high-water mark one; two concurrent runBackgroundExport() calls share one outcome and cancelling one caller's task expires the pass for both.+    - Blocked-by: 3rr2is7 (Add isBulkOperationInProgress() to LibraryProviding and LibraryRepository), 3rr2isa (Implement BackgroundExportPass, its outcomes, bounds, settle and log)+    - Stream: 1+    - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [1.10](requirements.md#1.10), [1.11](requirements.md#1.11), [3.5](requirements.md#3.5)+    - References: Asterism/AsterismTests/AppLibraryModelTests.swift+  - [x] 7.2. Implement runBackgroundExport, the session, the exclusion flags and the arrivals gate <!-- id:3rr2ish -->+    - Per design §Exclusion and §Resident versus cold. New state: bootstrapInFlight, backgroundPassHoldsLibrary, arrivalsDeferredByPass, backgroundSession, backgroundExportTask, mirroringOpenHooks (= .production; the cold open passes it to openForApp).+    - bootstrap(): bootstrapInFlight = true as the first statement, defer reset; drain loop `while let pass = backgroundExportTask { pass.cancel(); await pass.value }`; assert backgroundSession == nil; teardownRepository() also releases a stray backgroundSession defensively.+    - runBackgroundExport(): join an in-flight task through withTaskCancellationHandler(operation: { await task.value }, onCancel: { task.cancel() }); skip if bootstrapInFlight; otherwise create the task, which clears the handle as its last act. BackgroundExportSession conformance: acquire() rows from the design table (cold path starts the SyncMonitor before openForApp, onArrivals nil, maps CancellationError to .cancelled, stops/shuts down on every failing arm); release() idempotent; isPreempting = bootstrapInFlight.+    - handleSyncArrivals(): if backgroundPassHoldsLibrary { arrivalsDeferredByPass = true; return }; drainAndReconcile() runs the deferred handleSyncArrivals() first.+    - Configuration resolution shared with bootstrap() through one private helper.+    - Blocked-by: 3rr2isg (Write AppLibraryModelTests for the pass, pre-emption and reentrancy (red)), emption, emption, emption, emption, emption, emption, emption, emption, emption, emption, emption, emption, emption, emption+    - Stream: 1+    - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [1.10](requirements.md#1.10), [1.11](requirements.md#1.11), [3.5](requirements.md#3.5)+    - References: Asterism/Asterism/ViewModels/AppLibraryModel.swift++- [x] 8. Foreground marker clearing <!-- id:3rr2isi -->+  - Stream: 1+  - [x] 8.1. Write AppLibraryModelTests for armExportOwedClearing (red) <!-- id:3rr2isj -->+    - After bootstrap() on a mirroring root: a marker older than the persisted lastExportStarted is cleared at once; a newer one is cleared when observe() delivers a successful export with a later startDate, and left when the startDate is earlier; drainAndReconcile() re-arms and picks up a marker written since; teardownRepository() cancels the arm (no clear after a later event).+    - Blocked-by: 3rr2ish (Implement runBackgroundExport, the session, the exclusion flags and the arrivals gate)+    - Stream: 1+    - Requirements: [2.4](requirements.md#2.4)+    - References: Asterism/AsterismTests/AppLibraryModelTests.swift+  - [x] 8.2. Implement armExportOwedClearing and its arm points <!-- id:3rr2isk -->+    - armExportOwedClearing(): list, settle via BackgroundExportPass.settle against the monitor's status, then park syncMonitor.awaitExport(startedAfter: newest createdAt, deadline: nil) in a stored task that clears the outstanding names on .exported. Armed at the end of bootstrap() after startSyncObservation and in drainAndReconcile(); replacing an earlier arm cancels it; teardownRepository() cancels it. Compiles on every platform — it is the Mac's whole share of the feature (Q16).+    - Blocked-by: 3rr2isj (Write AppLibraryModelTests for armExportOwedClearing (red))+    - Stream: 1+    - Requirements: [2.4](requirements.md#2.4)+    - References: Asterism/Asterism/ViewModels/AppLibraryModel.swift++- [x] 9. Register the handler and the scene-phase submit in AsterismApp (iOS) <!-- id:3rr2isl -->+  - Under #if os(iOS): @Environment(\.scenePhase), a BackgroundExportScheduler, `.backgroundTask(.appRefresh(BackgroundExportScheduler.identifier)) { [model] in scheduler.submit(); _ = await model.runBackgroundExport(); scheduler.submit() }` on the WindowGroup, and `.onChange(of: scenePhase)` submitting on .background. Capture model at body evaluation (design §Scheduling explains the instance identity). Wiring only; the runbook proves it.+  - Blocked-by: 3rr2ise (Implement BackgroundExportScheduler), 3rr2ish (Implement runBackgroundExport, the session, the exclusion flags and the arrivals gate)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [3.1](requirements.md#3.1), [3.5](requirements.md#3.5)+  - References: Asterism/Asterism/AsterismApp.swift++- [x] 10. Development-only Settings trigger <!-- id:3rr2ism -->+  - Stream: 1+  - [x] 10.1. Write the UI test for the trigger row (red) <!-- id:3rr2isn -->+    - Development scheme, UI-test root (no marker): open Settings, expand the Debug disclosure (settings-debug-disclosure), assert settings-background-export-run exists, tap it, wait for settings-background-export-result to show the no-marker outcome sentence. The Personal scheme has no unit-test bundle, so the absence case is by inspection of the #if gate.+    - Blocked-by: 3rr2ish (Implement runBackgroundExport, the session, the exclusion flags and the arrivals gate)+    - Stream: 1+    - Requirements: [4.2](requirements.md#4.2)+    - References: Asterism/AsterismUITests/+  - [x] 10.2. Implement BackgroundExportTriggerModel and the Settings row <!-- id:3rr2iso -->+    - All under #if os(iOS) && DEBUG. AppLibraryModel.backgroundExportTriggerModel() → BackgroundExportTriggerModel (@MainActor @Observable; state idle/running/finished(sentence); run() calls runBackgroundExport()). SettingsView: a row inside the existing Debug DisclosureGroup following backupRow's state switch — Button "Run background export" (settings-background-export-run) → ProgressView → the outcome sentence (settings-background-export-result). The view chooses rows and styling; every sentence comes from the model, per SettingsView's convention.+    - Blocked-by: 3rr2isn (Write the UI test for the trigger row (red)), trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger, trigger+    - Stream: 1+    - Requirements: [4.2](requirements.md#4.2)+    - References: Asterism/Asterism/ViewModels/BackgroundExportTriggerModel.swift, Asterism/Asterism/Views/SettingsView.swift, Asterism/Asterism/ViewModels/AppLibraryModel.swift++## Extension++- [x] 11. Write the marker from ShareCaptureSession after a committed capture <!-- id:3rr2isp -->+  - Keep an ExportOwedMarker? on the session, built in bootstrap() step 1 from the resolved configuration beside the spool. In finish(discarding:from:outcome:), inside the existing Task { @MainActor in … }, after spool.discardPreserved and before completeExtension(), when outcome == .committed: `try? marker.mark()` with an os_log on failure (category BackgroundExport). Add the comment pinning the precondition: the marker is written after the commit is durable and must stay after it (design §The marker). Compiles into both extension targets; the Mac's markers are cleared by the Mac app's foreground arm.+  - Blocked-by: 3rr2is3 (Implement ExportOwedMarker and LibraryConfiguration.exportOwedURL)+  - Stream: 3+  - Requirements: [2.1](requirements.md#2.1), [2.5](requirements.md#2.5), [2.7](requirements.md#2.7)+  - References: Asterism/AsterismShareExtension/ShareCaptureSession.swift++## Documentation++- [x] 12. Write runbook.md and update prerequisites.md, CLAUDE.md and the specs overview <!-- id:3rr2isq -->+  - runbook.md: every device step is a device run under CLAUDE.md and needs approval at the moment of running; Development first, Personal last and only after a container download (xcrun devicectl … appGroupDataContainer, as runbook-log.md records). Steps: resident path (app suspended after an extension capture, `e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"<id>"]`, must end .exported with no activation — a .timedOut sends the resident row back to design, Q29); simulated expiration (_simulateExpirationForTaskWithIdentifier:) → .expired with the marker intact; cold launch (terminate from Xcode, not a swipe-kill; share; wait for a real grant; Console category BackgroundExport shows the pass id and no scene); Background App Refresh off → one refusal line per launch, foreground open still exports; marker protection class on device; second-install arrival (1.1) and a watch for early settlement (Q31).+  - prerequisites.md lines 10-19: replace the "superseded by T-2052" sentence with a pointer to specs/background-export/ as the closing of that gap. CLAUDE.md build-tooling section: Development builds carry the Settings trigger; verification of this feature is a device run under the existing rule. specs/OVERVIEW.md: status update for the row added at planning.+  - Blocked-by: 3rr2isl (Register the handler and the scene-phase submit in AsterismApp (iOS)), handler, handler, handler, handler, handler, handler, handler, handler, handler, handler, handler, handler, handler, handler, 3rr2iso (Implement BackgroundExportTriggerModel and the Settings row), 3rr2isp (Write the marker from ShareCaptureSession after a committed capture)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [3.5](requirements.md#3.5)+  - References: specs/background-export/runbook.md, specs/cloudkit-mirroring/prerequisites.md, CLAUDE.md, specs/OVERVIEW.md
specs/cloudkit-mirroring/prerequisites.md Modified +8 / -6
diff --git a/specs/cloudkit-mirroring/prerequisites.md b/specs/cloudkit-mirroring/prerequisites.mdindex e0b65a6..2ce7f43 100644--- a/specs/cloudkit-mirroring/prerequisites.md+++ b/specs/cloudkit-mirroring/prerequisites.md@@ -11,12 +11,14 @@ gate the spec itself (Decision 3), not just its implementation.       notifications** (commit `6006b6a`) to both App IDs and both share-extension App IDs, and       regenerate the provisioning profiles. Selecting CloudKit also configures       push; no user notification permission is required, and no background-      processing task is needed for mirroring alone. **Superseded for-      extension-capture latency by T-2052:** mirroring itself still needs no-      background task, but an extension capture reaches CloudKit only when the-      app is next opened (the extension writes through a `.none` store, Req-      5.1/5.4) — field evidence in `runbook-log.md`, second pass. Closing that-      gap is app-side background refresh, which is what T-2052 tracks.+      processing task is needed for mirroring alone. **The extension-capture+      latency gap is closed by `specs/background-export/` (T-2052):** mirroring+      itself still needs no background task, but an extension capture reached+      CloudKit only when the app was next opened (the extension writes through a+      `.none` store, Req 5.1/5.4) — field evidence in `runbook-log.md`, second+      pass. That spec adds an app-side app-refresh task which exports what the+      extension left behind; it needs no entitlement or profile change of its+      own, only `Info.plist` declarations. - [x] **Ran `docs/investigations/cloudkit-probe.md` — Q2 answered yes (2026-07-27).**       It decides whether the "no schema change" non-goal is reachable       (Decision 3), covering every generated mapping rather than one
specs/background-export/implementation.md Added +~200 / -0
(written during this review; see the Explanation tabs above)

Things to double-check

Rebase onto origin/main first.

origin/main gained T-2304 (#56) after this branch was cut. The three-dot diff excludes it; a plain two-dot diff shows those files reversed. Rebase before opening the PR.

Resident export on resume is a runbook proof, not a host proof (Q29).

If step 2 ends timed out, the resident row goes back to design; the only in-spec fallback contradicts Req 1.6.

Marker with an unreadable creation date settles on the first persisted export start.

.distantPast fallback in ExportOwedMarker.pending(). Theoretical on APFS; the alternative would hold every pass at its deadline.

The extension's marker write has no host test.

finish is not reachable from the unit bundle. Position and the log-and-ignore wrapper are verified by inspection and by runbook steps 1 and 2.