PR #46. The share extension preserves a pending-capture record before it opens the library, and the app drains on every activation, so the app committed the reader's page as an empty entry under the open sheet and the Save that followed lost the note. This branch adds a lapsing heldUntil lease on the record, discards the record on a swipe-dismiss, and turns the .raced(.edit) commit into a hand-off to the re-share editor.
PendingCaptureRecord.heldUntil (optional, absent when nil, format stays 1) plus PendingCaptureBounds.sheetHold = 15 min. isDeferred checks it before attempt history.preserve before the library open, which is the window the drain races. The failed-open arm calls releaseHold so the saved-for-later promise stays true.ShareSheetOutcome.dismissed: ShareViewController.viewDidDisappear discards the record when the reader swipes the sheet away. Best effort; the hold lapses if the delete does not land..raced(.edit) in CaptureViewModel.save now publishes a RacedCaptureHandoff; the root view flips ObservableReShareViewModel.ownsSheet and the reader lands in the re-share editor with both notes.make test-core exit 0 (no warnings), make build-ios exit 0, both re-run after the review edits. No device target was run.Ready to push
The mechanism is sound and small: one optional instant, written in the same atomic publish as the record, checked first by isDeferred, released on the failed-open arm and cleared on the first attempt. Every downgrade/upgrade direction is pinned by a test, the cross-process symptom is driven end to end, and make test-core and make build-ios are clean before and after the review edits. Four review agents raised nothing at major severity in the code. The one major finding was documentation: no decision-log entry and a stale design.md for a change with three real alternatives. That, a missing stamp-before-open test, and two misleading comments were fixed in the worktree and left uncommitted.
5d740e6 T-2287: investigation report and failing regression tests 12e08cf Fix T-2287: hold a live share sheet's record against the drain bd5f965 T-2287: solution comparison and final report working-tree Fixes applied in this review (uncommitted) When you share a web page to Asterism, the extension first writes a small "I owe you" file (a pending capture) so nothing is lost if anything goes wrong, and only then opens the library and shows the sheet where you type a note. The main app, whenever it comes to the front, looks through those IOU files and turns them into library entries. Before this fix, the app could grab your IOU while you were still typing, create an empty entry, and your Save then failed with "already saved" and threw your note away.
Now the IOU carries a note saying "a sheet owns me until 3:15pm". The app leaves such a file alone. If the sheet finishes normally the file is deleted as before; if you swipe the sheet away it is deleted too; if the extension is killed the note simply expires after fifteen minutes and the app adds the page later.
Empty entries stopped appearing on their own, and a note typed into the sheet is never discarded. Even in the rare case where the race still happens, the sheet now switches to the editor for the entry that already exists, with your note in it.
PendingCaptureSpool.swift: sheetHold bound, heldUntil field on the record (both inits, the shed path), preserve(heldUntil:) quantizes it, new releaseHold(id:), recordAttempt clears it, isDeferred checks it first.ShareCaptureFlow.swift: stamps sharedAt + sheetHold at preserve, releases on the failed-open arm, adds ShareSheetOutcome.dismissed.CaptureViewModel.swift: RacedCaptureHandoff with reconciledNote / reconciledRating; the .raced(.edit) arm publishes it and still sets saveFailed with the draft.LookupCaptureViewModel.enterEdit(handoff:) builds .readyEdit from the basis + draft.ObservableCaptureViewModel.onRaced callback, ObservableReShareViewModel.ownsSheet, root view prefers the lookup model once it owns the sheet, ShareViewController keeps a liveSheet tuple and discards in viewDidDisappear.The ownership state lives on the record rather than in a marker file or in-process state, because the two halves run in two processes and the file is the only thing both see. The hold is a deadline rather than a flag so it cannot leak. The failed-open arm releases explicitly because that arm promises immediate commit on next launch. The raced hand-off reuses the existing re-share editor rather than adding a new state to the capture sheet.
A killed appex delays its capture up to 15 min, including the .edit arm's idempotent re-share which previously landed on the next activation. Adding an optional key without a format bump means an older build drains the record immediately (the pre-fix behaviour), which is the accepted cost of not stranding captures on downgrade. The marker-file alternative and stamp-after-open alternative are recorded in solution-comparison.md and now in Decision 12.
isDeferred is the single eligibility question the drain asks (PendingCaptureDrain.swift:314), so adding the hold there covers every pass. The order matters: the hold is checked before lastAttemptAt, and recordAttempt nils it, so a lapsed hold does not travel through later rewrites. releaseHold goes through the same publish(..., requiringPresenceOf:) path as the attempt rewrites (Q47/Q52), so it cannot resurrect a record the sheet's cancel has just deleted; a .recordGone is swallowed with try?, which is correct because the drain committing it is the outcome the release moves towards. Synthesized Codable uses encodeIfPresent, so a nil hold produces byte-identical output to the pre-fix encoder; the test pins !text.contains("heldUntil").
The hand-off delivers exactly once: refreshState compares the observable's racedHandoff against the model's and fires onRaced only on the transition, after suppressDidSet is lowered. ownsSheet is never cleared, which is deliberate: the re-share editor's .saving/.saved states would otherwise flip the body back to the CaptureView branch.
Req 3.1 ("commit each preserved capture on activation") now has a second exception beside retry spacing; Decision 12 records it. ShareSheetOutcome is CaseIterable, so the existing pairing test covers .dismissed automatically.
viewDidDisappear also fires if the sheet ever presents a full-screen controller; nothing does today, and the comment now says so. A guard on isBeingDismissed would be belt-and-braces.[rawURL] rather than the lookup's full candidate set; deliberately out of scope and documented.PendingCaptureSpool.swift
Why it matters. This is the whole fix: the drain's only eligibility question now knows a sheet owns the record. Cleared on attempt, released on failed open, absent from the encoding when nil so format stays 1.
What to look at. PendingCaptureSpool.swift: sheetHold, heldUntil, preserve(heldUntil:), releaseHold(id:), recordAttempt, isDeferred
ShareCaptureFlow.swift
Why it matters. The open is the slow step and the race window. Stamping after it (the Kiro candidate) would narrow the bug instead of closing it. The failed-open arm's copy promises immediate commit, so it must give the hold back.
What to look at. ShareCaptureFlow.swift:245-280
ShareViewController.swift
Why it matters. Neither button callback runs on a swipe, so the record used to survive a decline and the drain committed it. Best-effort by nature: the process is being torn down.
What to look at. ShareViewController.swift: liveSheet, finish (liveSheet = nil), viewDidDisappear
CaptureViewModel.swift
Why it matters. The race is now rare but not impossible (sheet open > 15 min, double share). Without this arm the reader's note still dies. The saveFailed state is kept as the fallback for hosts that do not take the hand-off.
What to look at. CaptureViewModel.swift: RacedCaptureHandoff, save() .raced(.edit) arm
ShareCaptureRootView.swift
Why it matters. The body's branch is the swap. ownsSheet is never cleared so the re-share editor's saving/saved states do not flip back to the raced stack.
What to look at. ShareCaptureRootView.swift body + startNewCapture; ReShareCaptureView.swift ownsSheet/enterEdit; CaptureView.swift onRaced
PendingCaptureDrainTests.swift
Why it matters. The five-whys named the gap: flow tests had no drain, drain tests had no flow. anOpenShareSheetDoesNotBecomeAnEntry runs both over one directory; the spool tests pin both encoding directions.
What to look at. PendingCaptureDrainTests.swift, PendingCaptureSpoolTests.swift, ShareCaptureFlowTests.swift, CaptureStateTests.swift
One optional instant in the same atomic publish as the record; no cross-file invariant. Recorded in solution-comparison.md and now as Decision 12 in specs/pending-capture-queue/decision_log.md (added by this review).
The open is the race window. Stated in the flow's inline comment and the report.
An appex killed with the sheet open leaves nobody to release; Req 5.1 tolerates delay. 15 min outlasts any live sheet and is far short of the 24 h scavenge. Stated on sheetHold.
A higher format is retained and never committed by an older build (Req 9.5), so a bump would strand held records on downgrade. Optional key omitted when nil. Stated on heldUntil and pinned by a test.
For Settings and the extension's escalation copy. Stated on preserve.
Because the teardown reports it, not a button; the shared rule (discard, cancel request) is what the enum states. Stated on the case.
Deliberately out of scope: the narrow guard caught the drain's entry every time; widening changes which saves race across app and drain. Report, 'Deliberately not done'.
Previously it drained on the next activation as an idempotent re-share (Req 5.3). Not stated by the author; a consequence of stamping every sheet. Recorded in Decision 12's negative consequences.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | specs/pending-capture-queue docs | No decision-log entry for a change with three documented alternatives; design.md still listed the record without heldUntil, ShareSheetOutcome with two cases, the bounds table without sheetHold, and deferral as attempt-history only. | Added Decision 12 to decision_log.md and refreshed the four design.md sections. |
| minor | ShareCaptureFlowTests | No test distinguished stamp-before-open from stamp-after-open; both existing hold tests inspect the record after run() returns, so the rejected Kiro candidate would have passed them. | Added theHoldIsStampedBeforeTheOpen, asserting isDeferred from inside the open closure. Passes. |
| minor | ShareViewController.viewDidDisappear comment | Comment said a lost delete 'costs a delay, not the capture', but on this path the reader declined: a lost delete becomes an unwanted entry after the hold lapses. Also did not say why viewDidDisappear cannot fire for anything but a dismissal. | Rewrote the comment to state the real cost and the no-presentation assumption. |
| minor | CaptureView.swift racedHandoff doc | Doc said 'the root view watches it', but the root does not observe the capture model; delivery is through onRaced. | Corrected the doc comment. |
| nit | Efficiency: failed-open arm | releaseHold adds one read + one atomic rewrite before the saved-for-later confirmation is shown. Sub-millisecond on a 32 KB-capped file, on a path that already waited out a library-open timeout. | Not worth changing. |
| nit | CaptureViewModel.swift Q27 citation | Bare 'Q27' beside pending-capture-queue references is ambiguous (that spec's Q27 is the UTF-8 byte bound); the intended one is unified-teaching-composition Q27. Pre-existing usage in the same file. | Left as is; consistent with the surrounding file. |
| nit | Test coverage | Untested low-value branches: preserve quantizes heldUntil; reconciledNote dedupe when persisted == draft; .raced(.new) produces no handoff; enterEdit cursorAtEnd/workContext. | Left for the author; none affects the verdict. |
| nit | Req 3.1 wording | requirements.md 3.1 still says 'commit each preserved capture' on activation; the hold is a second exception. | Recorded as a consequence in Decision 12 rather than editing the requirement. |
Click to expand.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift b/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swiftindex 3a3d50b..d60bdac 100644--- a/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift@@ -42,6 +42,22 @@ public enum PendingCaptureBounds { /// switches apps, and a pass re-attempts the same unfinished head records on /// every activation while never reaching the tail behind them. public static let retryInterval: TimeInterval = 60 * 60+ /// How long a record preserved for a sheet the reader is looking at is held+ /// back from the drain (T-2287).+ ///+ /// The extension preserves *before* it opens the library (Decision 8), and+ /// the app drains on launch and on every activation, so without a hold the+ /// two processes together commit the reader's page — with no note and no+ /// rating — while the sheet they are typing into is still on screen.+ ///+ /// It lapses rather than only being released, because nothing can be relied+ /// on to release it: an appex killed with the sheet open leaves the record+ /// held with nobody left to clear it. Req 5.1 makes a delay acceptable where+ /// a loss is not, so the only question is how long. Fifteen minutes outlasts+ /// any sheet a reader is still using — a share sheet has no background life+ /// — and is far short of the day `incomingScavengeAge` waits before calling+ /// a file rubbish.+ public static let sheetHold: TimeInterval = 15 * 60 /// The age at which a file abandoned in `incoming/` is rubbish. public static let incomingScavengeAge: TimeInterval = 24 * 60 * 60 /// Records one drain pass commits before deferring the rest (Q24).@@ -168,6 +184,16 @@ public struct PendingCaptureRecord: Codable, Sendable, Equatable, Identifiable { public let safari: SafariPageMetadata? public var attempts: Int public var lastAttemptAt: Date?+ /// While this instant is in the future, a live capture sheet owns the record+ /// and the drain leaves it alone (T-2287).+ ///+ /// Optional and **absent** when unset, which is what makes it safe without a+ /// `format` bump (Req 9.5): a record declaring a higher format is retained+ /// and never committed by an older build, so a bump would strand captures on+ /// a downgrade. An older build's decoder ignores the extra key and drains+ /// the record at once — the pre-T-2287 behaviour, not a loss — and this+ /// build's decoder tolerates its absence in every record written before now.+ public var heldUntil: Date? public var disposition: Disposition /// The share this record preserves, for the drain to re-derive from.@@ -176,7 +202,13 @@ public struct PendingCaptureRecord: Codable, Sendable, Equatable, Identifiable { } /// Builds a record from a share, discarding any excess (Req 1.6).- public init(payload: SharePayload, sharedAt: Date, id: UUID = UUID()) {+ ///+ /// `heldUntil` is the share extension's alone: only a capture with a live+ /// sheet is held (T-2287), and every other producer of a record leaves it+ /// nil so the record is drainable the instant it lands.+ public init(+ payload: SharePayload, sharedAt: Date, id: UUID = UUID(), heldUntil: Date? = nil+ ) { self.format = Self.currentFormat self.id = id self.sharedAt = sharedAt@@ -198,6 +230,7 @@ public struct PendingCaptureRecord: Codable, Sendable, Equatable, Identifiable { } self.attempts = 0 self.lastAttemptAt = nil+ self.heldUntil = heldUntil self.disposition = .waiting } @@ -210,6 +243,7 @@ public struct PendingCaptureRecord: Codable, Sendable, Equatable, Identifiable { safari: SafariPageMetadata?, attempts: Int, lastAttemptAt: Date?,+ heldUntil: Date? = nil, disposition: Disposition ) { self.format = format@@ -220,6 +254,7 @@ public struct PendingCaptureRecord: Codable, Sendable, Equatable, Identifiable { self.safari = safari self.attempts = attempts self.lastAttemptAt = lastAttemptAt+ self.heldUntil = heldUntil self.disposition = disposition } @@ -335,15 +370,22 @@ public actor PendingCaptureSpool { /// quarantine rename is what delivers all three. What the extension must not /// do is pay to read the file to find out, which is why `read` checks the /// size first (Q48).+ ///+ /// `heldUntil` marks the record as owned by a live capture sheet until that+ /// instant (T-2287). It changes what `isDeferred` answers and nothing else:+ /// the counts returned here still count a held record as waiting, because it+ /// is one — it just is not drainable yet. @discardableResult public func preserve(- _ payload: SharePayload, sharedAt: Date, id: UUID = UUID()+ _ payload: SharePayload, sharedAt: Date, id: UUID = UUID(), heldUntil: Date? = nil ) throws -> PendingCaptureCounts { guard MillisecondInstant.isQuantized(sharedAt) else { throw PendingCaptureSpoolError.unquantizedShareInstant } try prepareDirectories()- let record = PendingCaptureRecord(payload: payload, sharedAt: sharedAt, id: id)+ let record = PendingCaptureRecord(+ payload: payload, sharedAt: sharedAt, id: id,+ heldUntil: heldUntil.map(MillisecondInstant.quantize)) if try areaBytes() >= PendingCaptureBounds.areaByteBound { // A killed writer's abandoned staging file counts towards the area,@@ -374,6 +416,31 @@ public actor PendingCaptureSpool { try remove(at: pendingURL(for: id)) } + /// Gives back a hold `preserve` stamped, because the sheet it was taken for+ /// never appeared (T-2287).+ ///+ /// The flow preserves before it opens the library (Decision 8), so the hold+ /// is stamped before it is known whether there will be a sheet at all. On+ /// the arm where the open fails the reader is told "this page will be added+ /// the next time you open Asterism", and a record still holding a fifteen+ /// minute hold would make that sentence false.+ ///+ /// Idempotent, and quiet about a record that is gone: the drain committing+ /// it in the meantime is the outcome this releases *towards*, not a failure.+ /// Rewrites through `incoming/` and requires the `pending/` entry, for the+ /// same reason the attempt rewrites do (Q47).+ public func releaseHold(id: UUID) throws {+ try prepareDirectories()+ guard case .record(let record) = read(pendingURL(for: id)), record.heldUntil != nil else {+ return+ }+ var released = record+ released.heldUntil = nil+ try publish(+ released, into: paths.pendingCapturesPendingURL, replacing: true,+ requiringPresenceOf: pendingURL(for: id), operation: "rewriting")+ }+ // MARK: Enumeration /// One pass over `pending/`: the records waiting for a drain, in share order@@ -487,6 +554,10 @@ public actor PendingCaptureSpool { var updated = record updated.attempts += 1 updated.lastAttemptAt = MillisecondInstant.quantize(clock.now())+ // A record only reaches an attempt once `isDeferred` says so, which a+ // live hold forbids: the sheet's claim on it has lapsed, so the stamp+ // goes rather than travelling on through every later rewrite (T-2287).+ updated.heldUntil = nil try publish( updated, into: paths.pendingCapturesPendingURL, replacing: true, requiringPresenceOf: pendingURL(for: record.id), operation: "rewriting")@@ -561,7 +632,14 @@ public actor PendingCaptureSpool { /// no-fault. Every other wait keeps its stamp and is spaced — including a /// record with no title yet, which would otherwise be re-attempted by every /// pass for as long as it stays untitleable.+ ///+ /// The hold is the second reason and is checked first, because it is not+ /// about attempt history at all: a record whose sheet is still on screen is+ /// not waiting for a drain — the reader is typing into it (T-2287). It+ /// bounds nothing else: a held record still *counts* as waiting, for the+ /// Settings count and for the extension's escalation copy alike. public static func isDeferred(_ record: PendingCaptureRecord, at instant: Date) -> Bool {+ if let heldUntil = record.heldUntil, instant < heldUntil { return true } guard let lastAttemptAt = record.lastAttemptAt else { return false } return instant.timeIntervalSince(lastAttemptAt) < PendingCaptureBounds.retryInterval }@@ -681,6 +759,7 @@ public actor PendingCaptureSpool { canonicalCandidates: safari.canonicalCandidates.dropLast()), attempts: candidate.attempts, lastAttemptAt: candidate.lastAttemptAt,+ heldUntil: candidate.heldUntil, disposition: candidate.disposition) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareCaptureFlow.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareCaptureFlow.swiftindex f411c9a..1813607 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ShareCaptureFlow.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareCaptureFlow.swift@@ -121,22 +121,30 @@ public enum ShareCaptureRequestOutcome: Sendable, Equatable { /// record and for the host. /// /// A value rather than a `Bool` on the controller's `finish` method, because the-/// pairing is the requirement: **both** outcomes delete the record (Req 1.2), and-/// they differ only in what the host is told. Asserting that in the appex is not-/// possible — the extension target has no test bundle — so the pairing lives+/// pairing is the requirement: **every** outcome deletes the record (Req 1.2),+/// and they differ only in what the host is told. Asserting that in the appex is+/// not possible — the extension target has no test bundle — so the pairing lives /// where a test can read it. public enum ShareSheetOutcome: Sendable, Equatable, CaseIterable { case committed case cancelled+ /// The reader swiped the sheet away, tapping neither Save nor X (T-2287).+ /// A third case rather than a synonym for `cancelled`, because it is the+ /// controller's *teardown* that reports it and not a button: what the two+ /// share is the rule below, and stating that is the whole point of this+ /// value.+ case dismissed - /// Req 1.2: the record goes on either outcome. Stated as a property so the- /// claim is checkable rather than a comment on two call sites.+ /// Req 1.2: the record goes on every outcome. A swipe-dismiss is a decline+ /// like the X — the reader left without saving — and leaving its record+ /// behind would turn that decline into an empty entry the next drain+ /// commits, which is the T-2287 symptom arriving by another door. public var discardsPreserved: Bool { true } public var request: ShareCaptureRequestOutcome { switch self { case .committed: .complete- case .cancelled: .cancel+ case .cancelled, .dismissed: .cancel } } }@@ -237,10 +245,17 @@ public struct ShareCaptureFlow<Opened> { // The id is minted here rather than inside the spool because it is the // handle the sheet's commit and cancel delete by. let preservedID = UUID()+ let sharedAt = MillisecondInstant.quantize(now()) let counts: PendingCaptureCounts do { counts = try await spool.preserve(- payload, sharedAt: MillisecondInstant.quantize(now()), id: preservedID)+ payload, sharedAt: sharedAt, id: preservedID,+ // T-2287 — stamped *here*, before the open, because the open is+ // where the race starts: the app drains on every activation, and+ // a record that is drainable for even the length of the open can+ // be committed as an empty entry under the sheet about to appear.+ // Stamping after a successful open would leave that window.+ heldUntil: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold)) } catch { return .notSaved(ShareCaptureCopy.notSaved) }@@ -254,6 +269,13 @@ public struct ShareCaptureFlow<Opened> { return .sheet( opened: opened, payload: payload, preservedID: preservedID, spool: spool) } catch {+ // No sheet was shown, so nothing owns the record and the hold taken+ // above is given straight back (T-2287): this arm's confirmation+ // promises the page will be added the next time the app is opened,+ // and a record still held for a quarter of an hour would make that+ // sentence false. Best-effort — a failure here costs a delay, not+ // the capture, because the hold lapses on its own.+ try? await spool.releaseHold(id: preservedID) return .savedForLater( message: ShareCaptureCopy.savedForLater(counts), preservedID: preservedID) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swiftindex be04217..1d0eab3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift@@ -31,6 +31,54 @@ public enum CaptureViewModelError: Error, Sendable, Equatable { case invalidState(String) } +/// Everything the sheet needs to carry a raced save into the re-share editor+/// instead of ending on "This chapter was already saved." (T-2287).+///+/// The race is what the hold makes rare rather than impossible — a sheet left+/// open past `PendingCaptureBounds.sheetHold`, or a capture the reader made+/// twice — so the arm that meets it has to lead somewhere. It carries the+/// resolved basis *and* the reader's draft, because the basis alone is the half+/// of the problem that was never in doubt: the note is the half that used to be+/// thrown away.+public struct RacedCaptureHandoff: Sendable, Equatable {+ /// The entry the commit found, with its persisted note and rating.+ public let basis: ReShareEditBasis+ /// The note the reader typed into the sheet that raced.+ public let draftNote: String+ /// The rating they set, if any.+ public let draftRating: Rating?++ public init(basis: ReShareEditBasis, draftNote: String, draftRating: Rating?) {+ self.basis = basis+ self.draftNote = draftNote+ self.draftRating = draftRating+ }++ /// The note the re-share editor opens with: both notes, neither discarded.+ ///+ /// The entry that won the race usually carries nothing — the drain commits+ /// no note (T-2287), which is the case this exists for — so the reader+ /// normally sees exactly what they typed. Where the entry *does* carry a+ /// note, the two are stacked oldest first with a blank line between, in an+ /// editor the reader can edit before tapping Update: silently dropping+ /// either one would repeat the defect in the other direction, and choosing+ /// between them is theirs to do, not ours.+ public var reconciledNote: String {+ let persisted = basis.persistedNote+ if draftNote.isEmpty { return persisted }+ if persisted.isEmpty || persisted == draftNote { return draftNote }+ return "\(persisted)\n\n\(draftNote)"+ }++ /// The rating the editor opens with: the reader's, where they set one.+ ///+ /// A rating is a single value, so there is no stacking it — and the reader+ /// touched this one seconds ago while the persisted one is whatever the+ /// entry already held. Where they set none, the entry's stands rather than+ /// being cleared.+ public var reconciledRating: Rating? { draftRating ?? basis.persistedRating }+}+ /// What one work-context read is keyed on: the work the sheet projects and /// *where in it* the chapter it is about to add sits. ///@@ -90,6 +138,13 @@ public final class CaptureViewModel { /// the sheet. public private(set) var workContext: ShareWorkContext = .empty + /// Non-nil once a save raced an entry the library already holds and the+ /// resolved disposition is editable (T-2287). The sheet watches it and+ /// switches to the re-share editor, seeded with the reader's draft; the+ /// state below stays a `saveFailed` carrying that draft, so a host that does+ /// not watch it still shows the reader their words rather than losing them.+ public private(set) var racedHandoff: RacedCaptureHandoff?+ /// What `workContext` describes, and what a read is out for. Both key on the /// projection rather than on `generation`, which advances on every keystroke /// and would discard a read the reader started typing during (Q12).@@ -372,10 +427,27 @@ public final class CaptureViewModel { message: "Cannot save: \(reason)" ) + case .raced(.edit(let racedBasis)):+ // A matching Entry appeared between the lookup and this commit+ // (Q27) — routine while the share extension preserves before it+ // opens the library, because the app's drain can commit the same+ // page under the open sheet (T-2287). The entry is editable, so+ // this is recoverable: hand the basis and the reader's draft to+ // the sheet, which re-enters the re-share editor with both notes+ // in front of them. The failed state below is the fallback for a+ // host that does not take the hand-off — it retains the draft.+ racedHandoff = RacedCaptureHandoff(+ basis: racedBasis, draftNote: draft.note, draftRating: draft.rating)+ state = .saveFailed(+ preparation: prep, draft: draft,+ outcome: contract.outcome,+ message: "This chapter was already saved."+ )+ case .raced:- // A matching Entry already exists (lookup-to-commit race, Q27);- // the ordinary app capture path does not set a race guard, so this- // is defensive. Retain the draft and surface the conflict.+ // A disposition that re-resolves to `.new` is not an entry to+ // edit, and `commitCapture` never answers it — the guard only+ // races into `.edit`. Defensive, and terminal as before. state = .saveFailed( preparation: prep, draft: draft, outcome: contract.outcome,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swiftindex bcb35b4..6b4fc33 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift@@ -190,6 +190,37 @@ public final class LookupCaptureViewModel { } } + // MARK: - Hand-off from a raced save (T-2287)++ /// Enters the edit state from a save that raced, rather than from a lookup.+ ///+ /// The same destination `.edit` reaches, arrived at from the other end: the+ /// new-capture sheet committed, met an entry the library already held, and+ /// hands over what it was holding. The reader's note and the entry's are+ /// reconciled by the hand-off, so what they get is both, in an editor they+ /// can still change before tapping Update.+ public func enterEdit(handoff: RacedCaptureHandoff) {+ let basis = handoff.basis+ currentEditBasis = basis+ lookupState = .readyEdit(ReShareEditState(+ entryID: basis.entryID,+ title: basis.title,+ persistedNote: basis.persistedNote,+ persistedRating: basis.persistedRating,+ firstCapturedAt: basis.firstCapturedAt,+ draftNote: handoff.reconciledNote,+ draftRating: handoff.reconciledRating,+ // Not at the end: the reader did not open this editor, they were+ // moved into it, and their own words are not necessarily its last+ // line — a persisted note is stacked above them.+ cursorAtEnd: false,+ errorMessage: nil,+ // The commit path's basis reads no work context (Q11), so the+ // characters row and the catch-up section stay empty rather than+ // showing something read for another entry.+ workContext: basis.workContext))+ }+ // MARK: - Draft editing public func setDraftNote(_ note: String) {
diff --git a/Asterism/AsterismShareExtension/ShareViewController.swift b/Asterism/AsterismShareExtension/ShareViewController.swiftindex be3fc89..6e00b3f 100644--- a/Asterism/AsterismShareExtension/ShareViewController.swift+++ b/Asterism/AsterismShareExtension/ShareViewController.swift@@ -24,6 +24,12 @@ final class ShareViewController: UIViewController { private var isFinishing = false /// Retained loading task for cancellation when the extension is dismissed. private var loadingTask: Task<Void, Never>?+ /// The record a live capture sheet owns, while one is on screen (T-2287).+ /// Held so the teardown below can discard it: a reader who swipes the sheet+ /// away taps neither Save nor X, so neither callback ever runs, and the+ /// record would otherwise sit waiting for the app to commit a page they+ /// declined.+ private var liveSheet: (preservedID: UUID, spool: PendingCaptureSpool)? override func viewDidLoad() { super.viewDidLoad()@@ -122,6 +128,7 @@ final class ShareViewController: UIViewController { // told something, not that a view was constructed. try? await Task.sleep(for: ShareCaptureCopy.savedForLaterDwell) case .sheet(let repository, let payload, let preservedID, let spool):+ liveSheet = (preservedID, spool) let root = ShareCaptureRootView( payload: payload, repository: repository,@@ -157,6 +164,7 @@ final class ShareViewController: UIViewController { ) { guard !isFinishing, !hasCompleted else { return } isFinishing = true+ liveSheet = nil Task { @MainActor in if outcome.discardsPreserved { await spool.discardPreserved(id: preservedID)@@ -168,6 +176,24 @@ final class ShareViewController: UIViewController { } } + /// The third way out of the capture sheet: the reader swipes it away,+ /// tapping neither Save nor X (T-2287).+ ///+ /// Neither button callback runs on that path, so without this the record+ /// stays waiting and the app commits, as an empty entry, a page the reader+ /// declined — the same symptom the hold fixes, arriving by another door. It+ /// is treated as a cancel, because that is what it is (`ShareSheetOutcome`+ /// pairs the two).+ ///+ /// Best-effort, and deliberately not more: the system is dismantling the+ /// extension while this runs, so the delete may not land. That costs a+ /// delay, not the capture — the hold lapses and the drain adds the page.+ override func viewDidDisappear(_ animated: Bool) {+ super.viewDidDisappear(animated)+ guard let live = liveSheet else { return }+ finish(discarding: live.preservedID, from: live.spool, outcome: .dismissed)+ }+ // MARK: - Hosting private func host(_ view: AnyView) {
diff --git a/Asterism/AsterismShareExtension/ShareCaptureRootView.swift b/Asterism/AsterismShareExtension/ShareCaptureRootView.swiftindex 0f6c290..484e620 100644--- a/Asterism/AsterismShareExtension/ShareCaptureRootView.swift+++ b/Asterism/AsterismShareExtension/ShareCaptureRootView.swift@@ -40,7 +40,10 @@ struct ShareCaptureRootView: View { var body: some View { Group {- if let captureViewModel {+ // The lookup model's edit state wins over a new-capture stack that+ // has already been built: that is how a raced save gets back to the+ // editor it should have started in (T-2287).+ if let captureViewModel, !lookupViewModel.ownsSheet { CaptureView( observableViewModel: captureViewModel, onSaveCompleted: onCompleted,@@ -85,6 +88,14 @@ struct ShareCaptureRootView: View { let captureModel = CaptureViewModel() let observable = ObservableCaptureViewModel(viewModel: captureModel) observable.setCoordinator(coordinator)+ // T-2287 — a save that meets an entry the library already holds is no+ // longer a dead end. The reader's note and the entry's are carried into+ // the re-share editor, where Update lands them on that entry. Moving the+ // lookup model into its edit state is the whole swap: the body draws+ // that model's editor whenever it is editing, whatever stack is built.+ observable.onRaced = { [lookupViewModel] handoff in+ lookupViewModel.enterEdit(handoff: handoff)+ } captureViewModel = observable Task { @MainActor in await captureModel.load(payload: payload, coordinator: coordinator)
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex abc2467..1327133 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -494,6 +494,15 @@ final class ObservableCaptureViewModel: ObservableObject { /// The projected work's cast and notes, filled in after the sheet renders /// (T-1916, T-1917). @Published var workContext: ShareWorkContext = .empty+ /// Non-nil once a save met an entry the library already held (T-2287). The+ /// root view watches it and moves the sheet into the re-share editor, so the+ /// reader's note lands on that entry instead of dying with the message.+ @Published var racedHandoff: RacedCaptureHandoff?+ /// Run once when `racedHandoff` first appears. A callback rather than an+ /// observed value because the root view holds this object in `@State` and+ /// does not observe it — only `CaptureView` does — and what has to happen is+ /// a swap of the whole stack, not a redraw of this one.+ var onRaced: ((RacedCaptureHandoff) -> Void)? @Published var manualTitle: String = "" { didSet { guard !suppressDidSet else { return }@@ -572,11 +581,16 @@ final class ObservableCaptureViewModel: ObservableObject { suppressDidSet = true state = viewModel.state workContext = viewModel.displayedWorkContext+ let raced = racedHandoff != viewModel.racedHandoff ? viewModel.racedHandoff : nil+ if let raced { racedHandoff = raced } // Sync binding values from the view model's current draft without triggering didSet loops if let draft = viewModel.currentDraft { if note != draft.note { note = draft.note } } suppressDidSet = false+ // After the publishes, and once: the handler replaces this whole stack+ // with the re-share editor (T-2287).+ if let raced { onRaced?(raced) } scheduleWorkContextLoad() }
diff --git a/Asterism/AsterismShareExtension/ReShareCaptureView.swift b/Asterism/AsterismShareExtension/ReShareCaptureView.swiftindex d68a46e..ce8c30a 100644--- a/Asterism/AsterismShareExtension/ReShareCaptureView.swift+++ b/Asterism/AsterismShareExtension/ReShareCaptureView.swift@@ -312,6 +312,12 @@ final class ObservableReShareViewModel: ObservableObject { return false } + /// Set once a raced save handed the capture back here (T-2287), and never+ /// cleared: the root view reads it to keep this model on screen for the rest+ /// of the sheet's life, including the `.saving` and `.saved` states after+ /// Update, which would otherwise flip back to the stack that raced.+ @Published private(set) var ownsSheet = false+ init(viewModel: LookupCaptureViewModel) { self.viewModel = viewModel self.state = viewModel.lookupState@@ -330,6 +336,14 @@ final class ObservableReShareViewModel: ObservableObject { } } + /// Re-enters the edit state from a save that raced (T-2287), carrying the+ /// reader's note into the entry the commit met.+ func enterEdit(handoff: RacedCaptureHandoff) {+ viewModel.enterEdit(handoff: handoff)+ ownsSheet = true+ refreshState()+ }+ /// The `.new` disposition basis, non-nil once the lookup proved zero matches. /// Drives the hand-off to the existing new-capture stack (Q26). var newCaptureBasis: NewCaptureEditState? {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureSpoolTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureSpoolTests.swiftindex 4cf7af1..e94bf2e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureSpoolTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureSpoolTests.swift@@ -529,6 +529,150 @@ struct PendingCaptureSpoolTests { } } + // MARK: - The sheet hold (T-2287)++ /// The hold is a second, independent reason to defer, and the record is the+ /// only place it can live: the sheet is in one process and the drain in+ /// another.+ @Test("A held record is deferred until the hold lapses, then is drainable (T-2287)")+ func theSheetHoldDefersARecord() async throws {+ let sharedAt = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_700_000_000))+ try await withSpool { spool, _ in+ _ = try await spool.preserve(+ SharePayload(providerURL: "https://example.com/a"), sharedAt: sharedAt,+ heldUntil: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold))+ let record = try #require(try await spool.pending().first)++ #expect(record.lastAttemptAt == nil)+ #expect(PendingCaptureSpool.isDeferred(record, at: sharedAt))+ #expect(+ PendingCaptureSpool.isDeferred(+ record,+ at: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold - 1)))+ #expect(+ !PendingCaptureSpool.isDeferred(+ record,+ at: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold + 1)))+ }+ }++ /// Req 2.5 and Req 7.2 count captures, not drainable ones. A held record is+ /// waiting — the reader has a sheet open on it — so the Settings count and+ /// the extension's escalation copy must both still see it.+ @Test("A held record still counts as waiting (T-2287)")+ func aHeldRecordStillCountsAsWaiting() async throws {+ let sharedAt = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_700_000_000))+ try await withSpool { spool, _ in+ let counts = try await spool.preserve(+ SharePayload(providerURL: "https://example.com/a"), sharedAt: sharedAt,+ heldUntil: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold))+ #expect(counts.waiting == 1)+ #expect(try await spool.waitingCount() == 1)+ }+ }++ /// The flow gives the hold back when the open failed and no sheet was shown,+ /// because that arm's confirmation promises the page will be added the next+ /// time the app opens. Idempotent, and quiet about a record the drain has+ /// already taken — that is the outcome it releases *towards*.+ @Test("Releasing the hold makes the record drainable at once, and repeats harmlessly (T-2287)")+ func releasingTheHoldRestoresDrainability() async throws {+ let sharedAt = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_700_000_000))+ try await withSpool { spool, _ in+ let id = UUID()+ _ = try await spool.preserve(+ SharePayload(providerURL: "https://example.com/a"), sharedAt: sharedAt, id: id,+ heldUntil: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold))++ try await spool.releaseHold(id: id)+ let released = try #require(try await spool.pending().first)+ #expect(released.heldUntil == nil)+ #expect(!PendingCaptureSpool.isDeferred(released, at: sharedAt))+ // Nothing else about the record moved: the release is not an attempt.+ #expect(released.attempts == 0)+ #expect(released.lastAttemptAt == nil)+ #expect(released.disposition == .waiting)++ try await spool.releaseHold(id: id)+ #expect(try await spool.pending().count == 1)+ try await spool.delete(id: id)+ try await spool.releaseHold(id: id)+ #expect(try await spool.pending().isEmpty)+ }+ }++ /// A record only reaches an attempt once the hold has lapsed, so the stamp+ /// has done its work and goes — rather than riding along through every later+ /// rewrite as a date nothing reads.+ @Test("An attempt clears a lapsed hold (T-2287)")+ func anAttemptClearsTheHold() async throws {+ let sharedAt = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_700_000_000))+ let attemptedAt = sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold + 60)+ try await withSpool(clock: FixedRepositoryClock(attemptedAt)) { spool, _ in+ _ = try await spool.preserve(+ SharePayload(providerURL: "https://example.com/a"), sharedAt: sharedAt,+ heldUntil: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold))+ let record = try #require(try await spool.pending().first)++ let attempted = try await spool.recordAttempt(record)+ #expect(attempted.heldUntil == nil)+ let reloaded = try #require(try await spool.pending().first)+ #expect(reloaded.heldUntil == nil)++ let released = try await spool.releaseAttempt(attempted, outcome: .noFault)+ #expect(released.heldUntil == nil)+ try await spool.setAside(released, reason: .invalidated)+ #expect(try await spool.setAsideRecords().first?.heldUntil == nil)+ }+ }++ /// The constraint the hold had to fit inside: `format` stays 1, because a+ /// record declaring a higher one is retained and never committed by an older+ /// build (Req 9.5), so a bump would strand captures on a downgrade.+ ///+ /// That costs both directions of tolerance, and both are asserted here. An+ /// unheld record carries no `heldUntil` key at all, so an older build's+ /// decoder sees exactly the bytes it always did; and a record written+ /// without the key — every record already on storage, and every one an older+ /// build rewrites — still decodes here and is drainable, which is the+ /// pre-T-2287 behaviour rather than a loss.+ @Test("A record written without the hold field still decodes and drains (9.5, T-2287)")+ func aRecordWithoutTheHoldFieldStillDecodes() async throws {+ let sharedAt = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_700_000_000))+ try await withSpool { spool, root in+ let paths = LibraryConfiguration(rootDirectory: root)+ _ = try await spool.preserve(+ SharePayload(providerURL: "https://example.com/a"), sharedAt: sharedAt)+ let unheld = try #require(try contents(of: paths.pendingCapturesPendingURL).first)+ let unheldText = try #require(+ String(data: try Data(contentsOf: unheld), encoding: .utf8))+ #expect(!unheldText.contains("heldUntil"))+ #expect(unheldText.contains("\"format\":1"))+ try await spool.delete(+ id: try #require(PendingCaptureSpool.identity(ofFileAt: unheld)))++ // Now the same record with a hold, stripped of the field the way an+ // older build's decoder would ignore it and its encoder would drop it.+ let id = UUID()+ _ = try await spool.preserve(+ SharePayload(providerURL: "https://example.com/b"), sharedAt: sharedAt, id: id,+ heldUntil: sharedAt.addingTimeInterval(PendingCaptureBounds.sheetHold))+ let published = paths.pendingCapturesPendingURL.appending(path: "\(id.uuidString).json")+ var body = try #require(+ try JSONSerialization.jsonObject(with: try Data(contentsOf: published))+ as? [String: Any])+ #expect(body["heldUntil"] != nil)+ body.removeValue(forKey: "heldUntil")+ try JSONSerialization.data(withJSONObject: body).write(to: published)++ let reloaded = try #require(try await spool.pending().first)+ #expect(reloaded.id == id)+ #expect(reloaded.heldUntil == nil)+ #expect(reloaded.providerURL == "https://example.com/b")+ #expect(!PendingCaptureSpool.isDeferred(reloaded, at: sharedAt))+ }+ }+ /// Q47: `recordAttempt` renames over the `pending/` entry, so before this /// guard it *recreated* a record the extension's cancel had just deleted — /// turning the reader's decline into a capture the next pass commits. The
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureDrainTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureDrainTests.swiftindex 5727bc0..9231d80 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureDrainTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureDrainTests.swift@@ -64,6 +64,50 @@ struct PendingCaptureDrainTests { #expect(fixture.entries().count == 1) } + /// T-2287, the symptom end to end: opening the share sheet on a page, with+ /// no tap on Save, must not put an entry in the library. The extension+ /// preserves before it opens the library (Decision 8) and the app drains on+ /// every activation, so the two processes together used to commit the+ /// reader's page — with no note and no rating — while the sheet they were+ /// typing into was still on screen. Their Save then failed with "This+ /// chapter was already saved." and took the note with it.+ ///+ /// Both halves are asserted: nothing is committed, and nothing is lost —+ /// the record is still there for the sheet to finish, or for a later pass.+ @Test("A drain that runs while a share sheet is open commits nothing (T-2287)")+ @MainActor+ func anOpenShareSheetDoesNotBecomeAnEntry() async throws {+ // A minute after the share: a sheet the reader is still typing into,+ // rather than one abandoned long enough for the hold to lapse.+ let fixture = try DrainFixture(+ clock: FixedRepositoryClock(DrainFixture.shareInstant.addingTimeInterval(60)))+ let sharedAt = MillisecondInstant.quantize(DrainFixture.shareInstant)++ let flow = ShareCaptureFlow<String>(+ resolve: { fixture.spool },+ extract: {+ SharePayload(providerURL: "https://ex.com/read/1", hostTitle: "Chapter 7")+ },+ open: { "repository" },+ now: { sharedAt })+ let decision = await flow.run()+ guard case .sheet(_, _, let preservedID, _) = decision else {+ Issue.record("expected the capture sheet, got \(decision)")+ return+ }++ let report = await fixture.drain.drain()++ #expect(report.newEntries == 0)+ #expect(report.committedEntryIDs.isEmpty)+ #expect(fixture.entries().isEmpty)+ // Nothing lost: the sheet's record is still where the sheet left it,+ // with its attempt budget untouched.+ let waiting = try await fixture.spool.pending()+ #expect(waiting.map(\.id) == [preservedID])+ #expect(waiting.first?.attempts == 0)+ }+ // MARK: - Exactly once across an interruption (Req 5.3) /// The window Q16 exists for: the commit persisted and the process died
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swiftindex a5c0620..2ec0087 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift@@ -55,6 +55,72 @@ struct ShareCaptureFlowTests { } } + // MARK: - The live sheet holds its record against the drain (T-2287)++ /// The bug: preserving before the open is what makes a capture safe, but a+ /// record whose sheet is still on screen is *not* waiting for a drain — the+ /// reader is typing into it. The app drains on every activation, so without+ /// a hold the record is committed as an empty entry while the sheet is open,+ /// and the reader's Save then meets "This chapter was already saved."+ ///+ /// Stated against `isDeferred`, which is the one question the drain pass+ /// asks of a record before it spends an attempt on it.+ @Test("A sheet's preserved record is not drainable while the sheet is live (T-2287)")+ func aLiveSheetHoldsItsRecord() async throws {+ try await withShareSpool { spool, root in+ let sharedAt = Self.shareInstant+ let decision = await Self.flow(spool: spool, opens: true, now: sharedAt).run()+ guard case .sheet(_, _, let preservedID, _) = decision else {+ Issue.record("expected the capture sheet, got \(decision)")+ return+ }++ // A fresh spool over the same directory, because the drain runs in+ // the app: what it sees is the file, not the extension's object.+ let reopened = PendingCaptureSpool(rootDirectory: root)+ let record = try #require(try await reopened.pending().first)+ #expect(record.id == preservedID)+ #expect(PendingCaptureSpool.isDeferred(record, at: sharedAt.addingTimeInterval(1)))+ }+ }++ /// The other half of the hold: it is a delay, never a loss. An appex killed+ /// with the sheet open leaves the record held with nobody to release it, so+ /// the hold has to lapse on its own — well inside a day, or "no capture is+ /// lost" becomes "no capture is lost this week".+ @Test("The hold lapses, so an abandoned sheet's capture is still drained (T-2287)")+ func theHoldLapses() async throws {+ try await withShareSpool { spool, root in+ let sharedAt = Self.shareInstant+ _ = await Self.flow(spool: spool, opens: true, now: sharedAt).run()++ let reopened = PendingCaptureSpool(rootDirectory: root)+ let record = try #require(try await reopened.pending().first)+ #expect(+ !PendingCaptureSpool.isDeferred(+ record, at: sharedAt.addingTimeInterval(24 * 60 * 60)))+ }+ }++ /// The over-fix guard. A failed open shows "this page will be added the next+ /// time you open Asterism", so its record must be drainable *now* — there is+ /// no sheet to hold it.+ @Test("A failed open leaves its record immediately drainable (2.1, T-2287)")+ func aFailedOpenLeavesItsRecordDrainable() async throws {+ try await withShareSpool { spool, root in+ let sharedAt = Self.shareInstant+ let decision = await Self.flow(spool: spool, opens: false, now: sharedAt).run()+ guard case .savedForLater = decision else {+ Issue.record("expected saved-for-later, got \(decision)")+ return+ }++ let reopened = PendingCaptureSpool(rootDirectory: root)+ let record = try #require(try await reopened.pending().first)+ #expect(!PendingCaptureSpool.isDeferred(record, at: sharedAt.addingTimeInterval(1)))+ }+ }+ // MARK: - Delete on outcome (Reqs 1.2, 1.3) /// Req 1.2 is one rule with two triggers: the record goes when the capture@@ -85,6 +151,13 @@ struct ShareCaptureFlowTests { #expect(ShareSheetOutcome.allCases.allSatisfy { $0.discardsPreserved }) #expect(ShareSheetOutcome.committed.request == .complete) #expect(ShareSheetOutcome.cancelled.request == .cancel)+ // T-2287: the third way out, the reader swiping the sheet away without+ // touching either button. The controller's teardown reports it, which no+ // test can drive, so what it reads is asserted instead — a decline like+ // the X, discarding the record so the app does not commit an empty entry+ // for a page the reader walked away from.+ #expect(ShareSheetOutcome.dismissed.discardsPreserved)+ #expect(ShareSheetOutcome.dismissed.request == .cancel) } @Test("A termination between preserving and either outcome leaves the record (1.3)")@@ -430,8 +503,13 @@ struct ShareCaptureFlowTests { /// themselves, standing in for the id the flow mints. nonisolated static let recordID = UUID() + /// A share instant the hold tests can do arithmetic around, quantized+ /// because `preserve` refuses anything else.+ nonisolated static let shareInstant = MillisecondInstant.quantize(+ Date(timeIntervalSince1970: 1_700_000_000))+ private static func flow(- spool: PendingCaptureSpool, opens: Bool+ spool: PendingCaptureSpool, opens: Bool, now: Date? = nil ) -> ShareCaptureFlow<String> { ShareCaptureFlow<String>( resolve: { spool },@@ -442,7 +520,8 @@ struct ShareCaptureFlowTests { operation: "opening library from extension") } return "repository"- })+ },+ now: { now ?? Date() }) } /// One row per failing open arm the extension can meet. They are listed
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftindex 82e0408..ed377fa 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -795,6 +795,121 @@ struct CaptureStateTests { let superseded = await viewModel.loadWorkContextIfNeeded(coordinator: coordinator) #expect(superseded == false) }++ // MARK: - A raced save is recoverable (T-2287)++ /// The arm the hold makes rare rather than impossible: a save that meets an+ /// entry the library already holds. It used to end on "This chapter was+ /// already saved.", with the reader's note retained in a state they could do+ /// nothing with — which is how T-2287 lost notes even after the entry it+ /// raced was accounted for.+ ///+ /// The hand-off is what makes it recoverable: the resolved basis *and* the+ /// draft, so the sheet can move into the re-share editor with both.+ @Test("A raced save hands its basis and the reader's draft to the sheet (T-2287)")+ @MainActor func aRacedSaveHandsOffTheDraft() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setNote("Kite runs out of thread", coordinator: fixture.coordinator)+ await fixture.viewModel.setRating(.up, coordinator: fixture.coordinator)++ // The entry the app's drain committed under the open sheet: no note, no+ // rating — the exact shape T-2287 produces.+ let basis = CaptureStateFixture.racedBasis(persistedNote: "", persistedRating: nil)+ fixture.coordinator.commitOutcome = .raced(.edit(basis))+ await fixture.viewModel.save(coordinator: fixture.coordinator)++ let handoff = try #require(fixture.viewModel.racedHandoff)+ #expect(handoff.basis.entryID == basis.entryID)+ #expect(handoff.draftNote == "Kite runs out of thread")+ #expect(handoff.draftRating == .up)+ // The entry carries nothing, so what the editor opens with is exactly+ // what the reader wrote.+ #expect(handoff.reconciledNote == "Kite runs out of thread")+ #expect(handoff.reconciledRating == .up)++ // The draft is still retained in the state as well, so a host that does+ // not take the hand-off shows the reader their words rather than a blank+ // sheet (the pre-T-2287 fallback, unchanged).+ guard case .saveFailed(_, let draft, _, let message) = fixture.viewModel.state else {+ Issue.record("Expected saveFailed, got \(fixture.viewModel.state)")+ return+ }+ #expect(draft.note == "Kite runs out of thread")+ #expect(message == "This chapter was already saved.")+ }++ /// The reconciliation rule, stated where it is decided: neither note is+ /// dropped. The entry's comes first because it was there first, and the+ /// reader edits the result before tapping Update — choosing between them is+ /// theirs to do.+ @Test("A raced save reconciles the entry's note with the reader's (T-2287)")+ @MainActor func aRacedSaveKeepsBothNotes() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setNote("Kite runs out of thread", coordinator: fixture.coordinator)++ let basis = CaptureStateFixture.racedBasis(+ persistedNote: "Noted on the first read", persistedRating: .down)+ fixture.coordinator.commitOutcome = .raced(.edit(basis))+ await fixture.viewModel.save(coordinator: fixture.coordinator)++ let handoff = try #require(fixture.viewModel.racedHandoff)+ #expect(handoff.reconciledNote == "Noted on the first read\n\nKite runs out of thread")+ // The reader set no rating, so the entry's stands rather than being+ // cleared by a draft that says nothing.+ #expect(handoff.reconciledRating == .down)++ // And the hand-off lands: the lookup model enters the editor it would+ // have reached had the lookup seen this entry, seeded with both notes.+ let lookup = LookupCaptureViewModel()+ lookup.enterEdit(handoff: handoff)+ guard case .readyEdit(let editState) = lookup.lookupState else {+ Issue.record("Expected readyEdit, got \(lookup.lookupState)")+ return+ }+ #expect(editState.entryID == basis.entryID)+ #expect(editState.draftNote == "Noted on the first read\n\nKite runs out of thread")+ #expect(editState.draftRating == .down)+ // The entry's own note stays visible as the persisted value, so the+ // reader can tell which half of the editor was already there.+ #expect(editState.persistedNote == "Noted on the first read")+ #expect(lookup.canSaveUpdate)+ }++ /// The over-fix guard: a note the reader never typed is not a note to carry.+ /// An empty draft leaves the entry's note exactly as it stands.+ @Test("A raced save with an empty draft leaves the entry's note alone (T-2287)")+ @MainActor func aRacedSaveWithNoNoteChangesNothing() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)++ let basis = CaptureStateFixture.racedBasis(+ persistedNote: "Noted on the first read", persistedRating: nil)+ fixture.coordinator.commitOutcome = .raced(.edit(basis))+ await fixture.viewModel.save(coordinator: fixture.coordinator)++ let handoff = try #require(fixture.viewModel.racedHandoff)+ #expect(handoff.reconciledNote == "Noted on the first read")+ #expect(handoff.reconciledRating == nil)+ }++ /// The ordinary paths must not sprout a hand-off: it is the one signal the+ /// sheet swaps its whole stack on.+ @Test("A committed or failed save hands off nothing (T-2287)")+ @MainActor func onlyARaceHandsOff() async throws {+ let committed = CaptureStateFixture.taughtSite()+ await committed.viewModel.load(+ payload: committed.defaultPayload, coordinator: committed.coordinator)+ await committed.viewModel.save(coordinator: committed.coordinator)+ #expect(committed.viewModel.racedHandoff == nil)++ let failed = CaptureStateFixture.taughtSite()+ await failed.viewModel.load(payload: failed.defaultPayload, coordinator: failed.coordinator)+ failed.coordinator.commitShouldThrow = true+ await failed.viewModel.save(coordinator: failed.coordinator)+ #expect(failed.viewModel.racedHandoff == nil)+ } } /// A contract carrying the outcome under test; only the outcome matters to the@@ -1115,6 +1230,20 @@ private struct CaptureStateFixture { ) } + /// The entry a commit races into (T-2287): the same page, already in the+ /// library, with whatever note and rating it happens to carry.+ static func racedBasis(persistedNote: String, persistedRating: Rating?) -> ReShareEditBasis {+ ReShareEditBasis(+ entryID: stableEntryID,+ hostname: "taught.example",+ identityKey: "taught.example/ch5",+ title: "Chapter 5",+ persistedNote: persistedNote,+ persistedRating: persistedRating,+ persistedModifiedAt: Date(timeIntervalSince1970: 1_721_000_000),+ firstCapturedAt: Date(timeIntervalSince1970: 1_721_000_000))+ }+ private init(coordinator: FakeCaptureCoordinating, payload: SharePayload) { self.coordinator = coordinator self.viewModel = CaptureViewModel()
diff --git a/specs/bugfixes/share-sheet-preserved-record-drained/report.md b/specs/bugfixes/share-sheet-preserved-record-drained/report.mdnew file mode 100644index 0000000..5906696--- /dev/null+++ b/specs/bugfixes/share-sheet-preserved-record-drained/report.md@@ -0,0 +1,229 @@+# Bugfix Report: Share sheet's preserved record is drained into an Entry before Save++**Date:** 2026-08-29+**Status:** Resolved+**Ticket:** T-2287++## Description of the Issue++Opening the share sheet on a page — without tapping Save — already produced a+library entry, with no note and no rating. Tapping Save afterwards failed with+"This chapter was already saved." and the typed note was lost.++**Reproduction steps:**++1. Share a page to Asterism from Safari.+2. Leave the capture sheet open (or dismiss it by swiping) without tapping Save.+3. Switch to the Asterism app — or have it already running, so that an+ activation drain runs.+4. Observe an entry for the page, with an empty note and no rating.+5. Return to the sheet and tap Save: it reports "This chapter was already saved."+ and the note is gone.++**Impact:** High. Every share taken while the app is running can produce an entry+the reader never approved, and any note or rating typed into that sheet is+discarded at Save. The library fills with empty entries and the reader's writing+is lost — the one thing the capture sheet exists to keep.++## Investigation Summary++- **Symptoms examined:** an entry appearing without a Save; the terminal+ "already saved" message on the Save that follows; the note not surviving it.+- **Code inspected:**+ - `Packages/AsterismCore/Sources/AsterismCore/ShareCaptureFlow.swift` — the+ preserve-before-open order (T-2217, Decision 8).+ - `Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift` — the+ record model and `isDeferred`, the drain's eligibility question.+ - `Packages/AsterismCore/Sources/AsterismCore/PendingCaptureDrain.swift` — the+ pass that commits a waiting record.+ - `Asterism/Asterism/ViewModels/AppLibraryModel.swift` — the drain on launch+ and on every activation.+ - `Asterism/AsterismShareExtension/ShareViewController.swift` and+ `ShareCaptureRootView.swift` — where the record is discarded, and the+ race-guard keys the `.new` arm commits with.+ - `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift`+ — the commit-time race guard that answers `.raced`.+ - `Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift` — the+ `.raced` arm that turns it into a terminal error.+- **Hypotheses tested and ruled out:**+ - *The share extension commits twice.* No: the extension's `.new` arm commits+ once and the guard is what refuses it; the first write comes from the app's+ drain, in a different process.+ - *T-1916 / T-1917's share-sheet additions.* No: both are shared-lock reads and+ write nothing.+ - *The record is discarded too late.* Not the cause: the discard order is+ correct; the record is simply drainable for the whole life of the sheet.++## Discovered Root Cause++A record preserved by the share extension carries no state saying a live sheet+owns it, and the drain's eligibility question is a pure function of attempt+history — `isDeferred` is false for any record with no `lastAttemptAt`+(`PendingCaptureSpool.swift:564-567`). A record written by+`ShareCaptureFlow.run()` before the library is opened is therefore drainable the+instant it lands, and the app drains on launch and on every activation, so the+app commits the reader's page as an empty entry while the sheet they are typing+into is still on screen.++**Defect type:** cross-process race, from a missing state in the shared record.++**Why it occurred (five whys):**++1. Why does opening the sheet create an entry? The app's drain commits the+ preserved record.+2. Why does the drain commit it? Nothing distinguishes a record whose sheet is+ live from one left behind by a failed open.+3. Why is there no such distinction? T-2217 (Decision 8) moved the preserve+ before the open, and the record's queue state is only `attempts`,+ `lastAttemptAt` and `disposition` — a "held by a live sheet" state was never+ modelled.+4. Why did that survive review and tests? The two halves live in two processes+ and two test suites: `ShareCaptureFlowTests` drives the extension's order with+ no drain, and `PendingCaptureDrainTests` drives the drain over records+ preserved by hand. No test ran a drain against a record the flow had just+ preserved.+5. Root cause: **drain eligibility is decided without asking whether the capture+ is still in the reader's hands.**++**Contributing factors:**++- The `.raced` arm of the capture sheet is terminal: it retains the draft in the+ view model but offers the reader no way to land it, so the race that the+ primary defect makes routine is also unrecoverable.+- The commit's race guard is narrower than the lookup that preceded it (the raw+ URL alone, against the lookup's raw + v2 + v3 candidates), so it can also miss+ a duplicate that matches only on an upgraded key.++## Resolution for the Issue++The record now says who owns it. `PendingCaptureRecord` carries an optional+`heldUntil`, and `PendingCaptureSpool.isDeferred` checks it before it looks at+attempt history: while the instant is in the future, the drain skips the record.++- **The hold is stamped at preserve time**, not after the open succeeds+ (`ShareCaptureFlow.run`). The open is where the race starts — a record+ drainable for even the length of the open can be committed under the sheet+ about to appear — so the window has to be closed before it, not after it.+ Its length is `PendingCaptureBounds.sheetHold`, **fifteen minutes**.+- **It lapses on its own.** Nothing can be relied on to release it: an appex+ killed with the sheet open leaves the record held with nobody left to clear+ it. A delay is acceptable, a loss is not (Req 5.1), so the hold expires rather+ than waiting for a release.+- **The failed-open arm gives it straight back** (`spool.releaseHold(id:)`).+ That arm shows "this page will be added the next time you open Asterism", and+ a record still held would make the sentence false. Best-effort — a failed+ release costs the delay, not the capture.+- **`format` stays 1.** A record declaring a higher format is retained and never+ committed by an older build (Req 9.5), so a bump would strand captures on a+ downgrade. `heldUntil` is absent from the encoding when unset, so an older+ build's decoder sees exactly the bytes it always did, and a record written+ without it decodes here and is drainable — the pre-fix behaviour, not a loss.+- **A swipe-dismiss now discards the record**, from `ShareViewController`'s+ `viewDidDisappear` rather than only from the two buttons: leaving without Save+ or X is a decline, and `ShareSheetOutcome.dismissed` pairs it with the cancel.+- **The `.raced` arm is recoverable.** `CaptureViewModel.save` hands the resolved+ basis and the reader's draft over as a `RacedCaptureHandoff`, and+ `ShareCaptureRootView` switches the sheet to `ReShareCaptureView` seeded with+ it. Neither note is dropped: the entry's comes first, the reader's below it+ with a blank line between, in an editor they can still change before tapping+ Update; the reader's rating wins where they set one, otherwise the entry's+ stands. The `saveFailed` state still retains the draft, so a host that does+ not take the hand-off is no worse off than before.++A held record still **counts** as waiting, for `PendingCaptureCounts`, the+Settings count and the extension's escalation copy. It is a waiting capture; it+just is not drainable yet.++## Regression Test++**Test files:**++- `Packages/AsterismCore/Tests/AsterismCoreTests/PendingCaptureDrainTests.swift`+ — `anOpenShareSheetDoesNotBecomeAnEntry` (the symptom end to end: the flow+ preserves, the drain runs, and nothing is committed while nothing is lost).+- `Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift`+ — `aLiveSheetHoldsItsRecord` (not drain-eligible while the sheet is live),+ `theHoldLapses` (an abandoned sheet's capture is still drained), and+ `aFailedOpenLeavesItsRecordDrainable` (the failed-open arm's record stays+ drainable now, as its confirmation promises).++Added with the fix:++- `PendingCaptureSpoolTests.swift` — the hold defers and then lapses, a held+ record still counts as waiting, `releaseHold` restores drainability and+ repeats harmlessly, an attempt clears a lapsed hold, and — the format+ constraint — a record written *without* the field still decodes and drains.+- `CaptureStateTests.swift` — the `.raced` hand-off carries the basis and the+ draft, both notes survive the reconciliation, an empty draft changes nothing,+ and no other commit outcome hands off.+- `ShareCaptureFlowTests.swift` — `ShareSheetOutcome.dismissed` discards and+ cancels, the value the appex teardown reads.++**Run command:** `make test-core`++## Affected Files++- `Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift` —+ `PendingCaptureBounds.sheetHold`, `PendingCaptureRecord.heldUntil`,+ `preserve(heldUntil:)`, `releaseHold(id:)`, the hold check in `isDeferred`,+ and the clear in `recordAttempt`.+- `Packages/AsterismCore/Sources/AsterismCore/ShareCaptureFlow.swift` — the+ stamp at preserve time, the release on the failed-open arm, and+ `ShareSheetOutcome.dismissed`.+- `Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift` —+ `RacedCaptureHandoff` and its reconciliation, published from the `.raced`+ arm of `save`.+- `Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift` —+ `enterEdit(handoff:)`.+- `Asterism/AsterismShareExtension/CaptureView.swift`,+ `ReShareCaptureView.swift`, `ShareCaptureRootView.swift` — the hand-off+ wiring that swaps the sheet into the re-share editor.+- `Asterism/AsterismShareExtension/ShareViewController.swift` — the live-sheet+ record and the swipe-dismiss teardown.++## Verification++**Automated:**++- [x] Regression tests pass — `anOpenShareSheetDoesNotBecomeAnEntry`,+ `aLiveSheetHoldsItsRecord`, `theHoldLapses`,+ `aFailedOpenLeavesItsRecordDrainable`+- [x] `make test-core` passes (2,074 tests, exit 0, re-verified on the+ integration branch)+- [x] No new compiler warnings — `swift build --package-path Packages/AsterismCore`+ and `make build-ios` both clean++**Note on flakiness:** two full `make test-core` runs across the candidate+branches and the integration branch each recorded one issue in a different+unrelated suite — `StoreMetadataTests` ("A store born at V5 reads as at-or-above+V5") and the bootstrap classification cross-product. Both are in the flaky+families `docs/agent-notes/testing.md` records, both pass in isolation and on the+base commit, and both cleared on a repeat run. The integration branch's own+`make test-core` is green.++## Prevention++- A record shared between two processes needs its ownership modelled in the+ record, not implied by which process wrote it.+- Where a feature's guarantee spans two processes, at least one test should drive+ both halves against the same directory. The two suites here each tested their+ own half and agreed with themselves.++## Related++- T-2217 (`specs/pending-capture-queue/`), Decision 8 — the preserve-first order.+- T-2222 — carrying the reader's note across a post-sheet failure. Deliberately+ not expanded into here.+- `solution-comparison.md` — the three competing implementations and why the+ field-based hold was chosen.++## Deliberately not done++The ticket's fourth fix direction — widening the commit's race-guard keys from+`[rawURL]` to the lookup's full candidate set (raw + v2 + v3) — is **not** part of+this fix. It is a real narrowness, but it is not what produced the bug: the+guard's job is to catch an entry that appeared after the lookup, and the entry+the drain created always carries the raw URL as its conservative key, so the+narrow guard caught it every time. Widening it changes which saves resolve to+`.raced` across the whole capture path, app and drain included, and belongs with+its own tests rather than riding along here.
diff --git a/specs/bugfixes/share-sheet-preserved-record-drained/solution-comparison.md b/specs/bugfixes/share-sheet-preserved-record-drained/solution-comparison.mdnew file mode 100644index 0000000..f866967--- /dev/null+++ b/specs/bugfixes/share-sheet-preserved-record-drained/solution-comparison.md@@ -0,0 +1,69 @@+# Solution Comparison: share-sheet-preserved-record-drained++Three implementations were run in parallel from the same commit (the failing+regression tests). All three converged on the same shape of answer — hold the+record while its sheet is live, let the hold lapse, discard on teardown, make+the `.raced` arm recoverable — and differ in where the hold is written and when+it is taken.++## Candidates++### Agent 1 — the hold as a field on the record (primary approach)++- **Commit:** `3a1d8d2` (`T-2287/candidate-hold-field`)+- **Files changed:** `PendingCaptureSpool.swift`, `ShareCaptureFlow.swift`,+ `CaptureViewModel.swift`, `LookupCaptureViewModel.swift`,+ `ShareViewController.swift`, `ShareCaptureRootView.swift`, `CaptureView.swift`,+ `ReShareCaptureView.swift`, three test files, the report+- **Lines changed:** +641 / −20+- **Tests:** all four regression tests pass; nine new tests; `make test-core`+ exit 0; `make build-ios` clean+- **Approach:** `PendingCaptureRecord` gains an optional `heldUntil`, stamped by+ the flow **at preserve time** for `PendingCaptureBounds.sheetHold` (15 min) and+ checked by `isDeferred` before attempt history; the failed-open arm releases it+ through a new `spool.releaseHold(id:)`.++### Agent 2 — the hold as a marker file (alternative approach)++- **Commit:** `ac4b8ad` (`T-2287/candidate-hold-lease`)+- **Files changed:** the same set, plus `LibraryConfiguration.swift` for a new+ `PendingCaptures/held/` directory+- **Lines changed:** +694 / −24+- **Tests:** all four regression tests pass; nine new tests; `make test-core`+ exit 0; `make build-ios` clean+- **Approach:** an empty marker file named for the record, written into a new+ `held/` area **before** the record is published; `enumerate()` stamps its+ presence onto a transient, non-`Codable` `isHeldBySheet`, so the record's+ encoded bytes never change. The lapse is measured from the record's own+ `sharedAt` (30 min). The marker is released on `delete`, `setAside`,+ `quarantineUnreadable` and both no-sheet arms, and stale markers are swept.++### Agent 3 — Kiro (independent perspective)++- **Commit:** `7e166e6` (`T-2287/candidate-kiro`)+- **Lines changed:** +342 / −22+- **Tests:** the four regression tests pass; two new tests; reports+ `make test-core` and `make build-ios` clean+- **Approach:** independently chose the same `heldUntil` field as Agent 1, but+ stamps it through a separate `spool.hold(id:until:)` **after** a successful+ open, for one hour.++## Selected: Agent 1++Agent 3 is disqualified on correctness: stamping the hold only after the open+leaves the open itself drainable, and the open is the slow step — it carries an+interactive timeout and is exactly where a busy library stalls. That is the+bug's own window, narrowed rather than closed. It also has the thinnest test+coverage of the three.++Between Agents 1 and 2, both are correct and both close the window. Agent 1 is+chosen for the smaller mechanism. Its hold is one optional instant written in+the same atomic publish as the record it holds, so there are no invariants to+maintain between two files; Agent 2's marker has to be released on four separate+spool paths and swept on a fifth, and it adds a directory to the App Group+container plus a transient field on a `Codable, Equatable` record. Agent 2's one+advantage — the record's encoded shape is untouched — is worth less than it+looks: `heldUntil` is absent from the encoding when unset, an older build ignores+the key and drains the record immediately (the pre-fix behaviour, not a loss),+and a test pins both directions. In a subsystem whose entire purpose is "no+capture is ever lost", the version a future reader can hold in their head wins.
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex 1327133..951d8fe 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -494,9 +494,10 @@ final class ObservableCaptureViewModel: ObservableObject { /// The projected work's cast and notes, filled in after the sheet renders /// (T-1916, T-1917). @Published var workContext: ShareWorkContext = .empty- /// Non-nil once a save met an entry the library already held (T-2287). The- /// root view watches it and moves the sheet into the re-share editor, so the- /// reader's note lands on that entry instead of dying with the message.+ /// Non-nil once a save met an entry the library already held (T-2287).+ /// Nothing observes it directly — the root view is told through `onRaced`+ /// below — it is the once-only guard for that call, and what a later+ /// `refreshState` compares against so the handler is not run twice. @Published var racedHandoff: RacedCaptureHandoff? /// Run once when `racedHandoff` first appears. A callback rather than an /// observed value because the root view holds this object in `@State` anddiff --git a/Asterism/AsterismShareExtension/ShareViewController.swift b/Asterism/AsterismShareExtension/ShareViewController.swiftindex 6e00b3f..dca1f6e 100644--- a/Asterism/AsterismShareExtension/ShareViewController.swift+++ b/Asterism/AsterismShareExtension/ShareViewController.swift@@ -186,8 +186,16 @@ final class ShareViewController: UIViewController { /// pairs the two). /// /// Best-effort, and deliberately not more: the system is dismantling the- /// extension while this runs, so the delete may not land. That costs a- /// delay, not the capture — the hold lapses and the drain adds the page.+ /// extension while this runs, so the delete may not land. When it does not,+ /// the hold lapses and the drain adds the page the reader declined — the+ /// pre-fix outcome for this path, bounded now to the one case where the+ /// process dies under the delete.+ ///+ /// This fires for a dismissal only because the sheet presents nothing over+ /// itself: `host(_:)` swaps child controllers, which never reaches the+ /// parent's appearance callbacks, and no arm presents a controller. A+ /// full-screen presentation added from the sheet would land here too and+ /// cancel the request underneath the reader. override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) guard let live = liveSheet else { return }diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swiftindex 2ec0087..a1b32e0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCaptureFlowTests.swift@@ -84,6 +84,35 @@ struct ShareCaptureFlowTests { } } + /// Where the hold is taken matters as much as that it is: the open is the+ /// slow step, and a record drainable for its length can be committed under+ /// the sheet about to appear. Asserted from inside the open, which is the+ /// one vantage point that tells a stamp-before-open apart from a+ /// stamp-after-open that the other tests cannot distinguish.+ @Test("The hold is already on the record when the open begins (T-2287)")+ func theHoldIsStampedBeforeTheOpen() async throws {+ try await withShareSpool { spool, root in+ let sharedAt = Self.shareInstant+ let flow = ShareCaptureFlow<String>(+ resolve: { spool },+ extract: { Self.payload },+ open: {+ let reopened = PendingCaptureSpool(rootDirectory: root)+ let record = try #require(try await reopened.pending().first)+ #expect(+ PendingCaptureSpool.isDeferred(+ record, at: sharedAt.addingTimeInterval(1)))+ return "repository"+ },+ now: { sharedAt })+ let decision = await flow.run()+ guard case .sheet = decision else {+ Issue.record("expected the capture sheet, got \(decision)")+ return+ }+ }+ }+ /// The other half of the hold: it is a delay, never a loss. An appex killed /// with the sheet open leaves the record held with nobody to release it, so /// the hold has to lapse on its own — well inside a day, or "no capture isdiff --git a/specs/pending-capture-queue/decision_log.md b/specs/pending-capture-queue/decision_log.mdindex 0074b3c..ecb1f6e 100644--- a/specs/pending-capture-queue/decision_log.md+++ b/specs/pending-capture-queue/decision_log.md@@ -802,3 +802,65 @@ share resolves its title from the payload with no network at all. **Negative:** - A drained re-share of a non-Safari share pays for a title fetch whose result is then unused. - The design's step-3 ordering had to be rewritten, and the no-title row of the classification table now sits *after* the lookup: an `.edit` match commits a re-share even when no title could be resolved, because a re-share needs no title.++---++## Decision 12: A Live Sheet Holds Its Record With a Lapsing `heldUntil` Stamp++**Date**: 2026-08-29+**Status**: accepted++### Context++Decision 8 preserves the record *before* the library is opened, and the app+drains on launch and on every activation. Nothing on the record said a sheet+owned it, so the app committed the reader's page — with no note and no rating —+while they were still typing into the sheet, and their Save then met the `.raced`+arm and lost the note (T-2287, `specs/bugfixes/share-sheet-preserved-record-drained/`).++### Decision++`PendingCaptureRecord` gains an optional `heldUntil`. The flow stamps+`sharedAt + PendingCaptureBounds.sheetHold` (15 minutes) at preserve time, before+the open; `isDeferred` answers true while the instant is in the future; the+failed-open arm releases the hold immediately; `recordAttempt` clears a lapsed+one. `format` stays 1. A swipe-dismiss discards the record from the controller's+teardown (`ShareSheetOutcome.dismissed`), and the `.raced(.edit)` commit hands+the reader's draft into the re-share editor instead of ending on a message.++### Rationale++The stamp has to precede the open because the open is the slow step and is+exactly the window the drain races. It lapses rather than only being released+because an appex killed with the sheet up leaves nobody to release it; Req 5.1+tolerates a delay and not a loss, and 15 minutes outlasts any sheet still in+use while staying far short of the 24-hour scavenge. An optional key without a+`format` bump is what keeps a downgrade safe: an older build ignores the key and+drains at once (the pre-fix behaviour), and a bump would strand every held record+under Req 9.5.++### Alternatives Considered++- **Marker file in a `held/` directory, hold measured from `sharedAt`**: keeps+ the record's bytes untouched, but the marker has to be released on four spool+ paths and swept on a fifth, and adds a directory plus a transient field on a+ `Codable, Equatable` record — two invariants where one field carries none.+- **Stamp after a successful open, via a separate `hold(id:until:)`**: leaves+ the open itself drainable, which is the bug's own window narrowed rather than+ closed.+- **A `format` bump**: strands held records on any older build (Req 9.5).++### Consequences++**Positive:**+- The drain no longer creates entries under an open sheet, and the reader's+ words survive the race that remains.+- One field, written in the same atomic publish as the record; no cross-file+ invariant.++**Negative:**+- A sheet whose process died is drained up to 15 minutes late, including the+ `.edit` arm's idempotent re-share that used to land on the next activation.+- Req 3.1's "commit each preserved capture on activation" now has a second,+ reader-facing exception beside the retry spacing.+diff --git a/specs/pending-capture-queue/design.md b/specs/pending-capture-queue/design.mdindex 1cbb8c5..7023411 100644--- a/specs/pending-capture-queue/design.md+++ b/specs/pending-capture-queue/design.md@@ -210,6 +210,14 @@ record cap each time, which is the starvation the interval exists to prevent (Q20 as three times amended). Req 6.5's "no failure counted" is unaffected: the spacing consumes nothing from the attempt budget. +A third reason to skip a record, checked before either of the above: `heldUntil`+in the future (T-2287, Decision 12). The extension preserves before it opens the+library, so it stamps `sharedAt + sheetHold` on the record it preserves; while+that instant has not passed the record belongs to a sheet the reader may still+be typing into, and the drain leaves it alone. The failed-open arm releases the+hold at once (`releaseHold`), `recordAttempt` clears a lapsed one, and a held+record still counts as waiting everywhere a count is shown.+ ### Req 3.3 across passes Req 3.3's ordering holds because enumeration is in share order — *within one@@ -284,6 +292,7 @@ public struct PendingCaptureRecord: Codable, Sendable, Equatable { public let safari: SafariPageMetadata? // gains Codable public var attempts: Int public var lastAttemptAt: Date?+ public var heldUntil: Date? // T-2287, Decision 12: absent unless a live sheet owns the record public var disposition: Disposition // .waiting, .setAside(reason), .unreadable, .refused public enum Disposition: Codable, Sendable, Equatable { … }@@ -501,8 +510,8 @@ public enum ShareCaptureDecision<Opened> { /// they differ only in what the host is told (Req 2.4). The appex has no test /// bundle, so the pairing lives where a test can read it. public enum ShareSheetOutcome: Sendable, Equatable, CaseIterable {- case committed, cancelled- public var discardsPreserved: Bool // true for both+ case committed, cancelled, dismissed // dismissed: swiped away, reported by the controller's teardown (T-2287)+ public var discardsPreserved: Bool // true for every case public var request: ShareCaptureRequestOutcome } ```@@ -663,6 +672,7 @@ defaulted, so no existing caller changes behaviour. `SafariPageMetadata` gains | Area byte bound | 8 MB | | Attempt limit | 5 | | Retry interval | 1 hour |+| `sheetHold` | 15 minutes — a live sheet's record is not drainable until it lapses (Decision 12) | | `incoming/` scavenge age | 24 hours | | Records committed per pass | 25 | | Drain pass time budget (activation) | 10 seconds |
viewDidDisappear spawns a Task that awaits discardPreserved and then cancelRequest. If the system kills the appex first, the record survives with its hold and the drain commits the declined page after 15 min. Verify on device that a swipe-dismiss leaves no pending record in Settings.
The hold lapses under a still-open sheet; a drain then commits, and the reader's Save takes the new .raced(.edit) hand-off. Worth one manual pass to see the re-share editor appear with the note intact.
An older build ignores heldUntil and drains immediately, the pre-fix behaviour. Acceptable by design; confirm no build in the field has a strict decoder.