A data-integrity fix for a race between two overlapping Save As operations on a pasted document. Three commits plus review fixes; PR #367.
PendingSave.id), run attempts in order instead of racing them, and let only the current attempt finalise.specs/clipboard-notes/ Decision 6 and Requirement 4.5 at SHALL level. That divergence lived only in a bugfix report. Added Decision 7 superseding Decision 6's failure branch, amended Req 4.5 (which was false as written), and added Req 4.7 for the ordering rule.handleSaveFailed did not clear the persisted clipboard state when settling onto a file. Since toPersistableState() returns nil for a file source, the stale entry could never be overwritten — restoring a ghost copy of the pasted document on the next launch, under a clipboard identifier whose notes file the chain had already deleted.notesRemainAt == nil branch (the common single-attempt failure, and the one Decision 6 actually describes) had no test. A mutation turning every first-attempt failure into a file save passed the entire suite. Added failedFirstAttemptStillRevertsToTheClipboard; mutation-checked, and it is the only test that catches it.previous?.cancel() — inert today, but it contradicts the design rule three lines above it and arms itself the moment any notes I/O becomes cancellation-aware. Also corrected three citations pointing at the wrong spec (clipboard-render → clipboard-notes, where both Decision 6s exist but mean different things) and a stale method signature in a doc comment.Ready to push
The concurrency reasoning holds under tracing. Three reviewers independently checked the load-bearing claim — that nothing on the notes path is cancellation-sensitive, so serialising attempts rather than cancelling them is safe — and it is true today at every level of the call chain (NotesManager.migrateNotes → NotesStore.save/delete are synchronous bodies inside an actor; the only suspensions are actor hops). Each new guard is pinned by a test that fails when the guard is mutated away, and I verified the new one myself by mutation.
Two real issues were found and fixed in this review: the divergence from a SHALL-level requirement was recorded nowhere in specs/, and the new failure branch left stale persisted clipboard state behind that would restore a ghost document on the next launch. Both are now closed. What remains is nits — a dead cancel() call (removed), and a handful of stylistic duplications not worth churning.
e463d62 T-1812: Stop consecutive Save As operations cross-wiring notes and bookmarks 9e37343 Fix T-1812: restore the save chain's origin when a migration fails fdac989 Fix T-1812: report where the notes are when a migration fails working-tree Fixes applied in this review Prism lets you paste text and read it as a document, and add notes to it. When you save that pasted document to a real file, the notes have to move too — they were filed under a temporary name ("the thing you pasted"), and they need to be re-filed under the real filename.
That re-filing takes a moment. During it, Prism still considers the document unsaved, so the Save button stays visible. If you clicked Save again quickly and picked a second filename, two saves would be running at once — and they got tangled up.
The tangle: the second save looked at your notes, saw they were already labelled with the first filename, concluded "these aren't mine to move", and left them alone. Your document became the second file. Your notes stayed with the first file. Nothing on screen showed them, and deleting that first file would have taken your notes with it.
Losing annotations silently is about the worst thing an app can do with them — worse than an error message, because you have no idea it happened. The notes were still on disk, but nothing pointed at them any more, so from your point of view they had vanished.
Two smaller problems travelled with it: the slower save could finish afterwards and drag the document back to the file you had just saved past, and the Recent Files list could get an entry labelled with one filename that actually opened a different file.
If the notes genuinely can't be written (disk full, no permission), Prism now tells you which file your notes are on, and opens the document as that file — rather than pointing you at the temporary pasted-text storage, which by that point has already been cleaned out and holds nothing.
Five production files plus one new type:
DocumentSession.swift — pendingSaveURL: URL? becomes pendingSave: PendingSave?, a struct carrying a monotonic per-session id alongside the URL. pendingSaveURL survives as a derived accessor. New isCurrentSaveAttempt(_:).ClipboardSaveFlow.swift (new, ~215 lines) — the save flow lifted out of DocumentReaderView's .onChange closure into a testable @MainActor type.NotesManager.swift — migrateNotes gains a previousDestination: parameter, a post-await identity re-check, and a same-file delete guard.DocumentFlowCoordinator.swift — pendingSaveBookmarkData: Data? becomes pendingSaveBookmark: (url: URL, data: Data)?; handleSaveFailed gains a notesRemainAt: parameter.DocumentReaderView.swift — observes pendingSave instead of pendingSaveURL and delegates to the flow. Net −12 lines.The change reads as three rules layered on one another.
1. Attempts have identity. The old pendingSaveURL looked like an identity but wasn't — it cannot distinguish two attempts to the same URL, which is a real case (the user confirms an overwrite while the first save is still migrating). A monotonic counter can. Note the view's .onChange had to move to pendingSave too: keyed on the URL, a second save to the same file would not have fired at all.
2. Attempts run in order, not in parallel. The intuitive fix is to cancel the in-flight attempt, and it was explicitly rejected. Two reasons: nothing on this path checks Task.isCancelled, so cancellation is advisory noise; and migrateNotes has already rewritten the in-memory note identifier by its first suspension point, so abandoning it halfway is worse than letting it finish. Instead a new attempt captures the previous task and awaits it (await previous?.value) before doing its own work.
3. Only the current attempt finalises. Every step after an await is gated on session.isCurrentSaveAttempt(attempt). A superseded attempt does its notes work and then returns without touching the session source, the completion callback, or the bookmark.
On top of those sits previousDestination: the URL an earlier attempt in this chain migrated the notes to, threaded into migrateNotes as an additional acceptable starting identity. The original guard — "these notes must still carry the clipboard identifier" — is a precondition on the first attempt only. A second attempt legitimately starts from the first's destination, and reading that shape as "not mine" is precisely what stranded the notes.
Serialising vs. cancelling. Serialising means N consecutive saves do N full migrations sequentially. That is real work, but N is bounded by completed save-panel round-trips — a user cannot enqueue attempts faster than they can dismiss a file picker — and each migration is one small JSON write inside an actor, off the main thread. Cheap insurance.
Threading previousDestination vs. storing provenance in NotesManager. The manager could simply remember "this session's notes moved to A". Rejected, and rightly: the manager has no way to know when a save chain ends, so a long-lived flag would silently make "export a copy" of an already-saved document move its notes — a much larger behaviour change than the bug being fixed. The cost of the chosen design is that previousDestination's safety rests on a prose caller contract rather than something the callee can check. That is the sharpest edge in the change, and it is the one with the most test coverage.
Where the session lands after a failure. Covered under Decision 7 below — the one genuinely contentious call.
The cancellation claim, verified. The design rests on "nothing on this path checks Task.isCancelled", which is load-bearing enough to trace rather than trust. It holds: NotesStore.save/delete/load have fully synchronous bodies inside an actor, so the only suspensions in migrateNotes are actor hops — not cancellation points. loadNotes → backupStore.backupIfNeeded → persistNotes is likewise synchronous file I/O, and NotesManager already carries a comment recording that loadNotes never checks cancellation. No Task {}, async let, or task group is created anywhere in run's call tree, so inherited cancellation has nothing to act on. Finally, await previous?.value on a Task<Void, Never> is not cancellation-throwing, and an unstructured Task cancelled before it starts still runs its body — so ordering survives even a cancelled predecessor.
That verification is exactly why the previous?.cancel() call was removed in this review. It was inert, but it contradicted the rule stated three lines above it, and it is the precise line that arms itself the day any of that I/O moves to a cancellation-aware API — store.save would start throwing CancellationError, migrateNotes would return false, and every superseded attempt would drive the new user-visible failure banner.
Serialisation is genuinely guaranteed. ClipboardSaveFlow is @MainActor and start() is fully synchronous — no await between reading session.pendingSave, capturing previous, and reassigning task. Attempt order is therefore fixed at call time, and every read/write of previousDestination in run happens on the MainActor with no interleaving suspension between a guard and its dependent mutation.
The two migrateNotes post-await guards return different values, correctly. The success-path guard returns true when the in-memory notes were replaced mid-flight: the store write already landed at the target, so the notes did move, and returning false would send the flow into finaliseFailure and settle the session onto a file that no longer holds them. The catch-path guard returns false: .write(options: .atomic) means a throw guarantees nothing landed, so the notes are still at the source — and refusing to overwrite a documentNotes another load has replaced is right.
Ordering subtlety worth noticing. On failure, previousDestination = migrationSource runs ahead of the currency guard, while the user-visible finalisation runs behind it. That asymmetry is deliberate and correct: the bookkeeping records where the notes physically are, which is true whether or not a newer attempt has superseded this one, whereas only the current attempt may talk to the user. Getting this backwards reproduces the ticket's own bug shape one chain-step deeper — which is what commit 9e37343 was fixing.
The change extracts the save flow from a view closure into a testable @MainActor type, which is what made a nine-case regression suite possible at all. It follows T-1811's established shape (identity captured before the first await, re-checked after every suspension, write-side check placed where the damage would otherwise be done) rather than inventing a parallel mechanism — I checked whether loadGeneration/startSignInReload could have been reused and they could not: they guard load results inside NotesManager, while PendingSave.id guards save-attempt finalisation across three types.
Deliberately not generalised: there is no reusable serialised-task helper here, and all thirteen other Task<Void, Never>? properties in the codebase remain cancel-and-replace. That is the right call — this path is the one place where cancel-and-replace is actively wrong, and hoisting it into a shared abstraction would invite it somewhere it doesn't belong.
A mid-chain migration failure now calls session.didSave(to:) on the earlier attempt's file rather than revertToClipboard(). This contradicts specs/clipboard-notes/ Decision 6 and Requirement 4.5 at SHALL level — and until this review, that divergence was recorded only in a bugfix report, which is not the spec.
The change is right. The invariant Decision 6 protects is "notes are never left unreachable"; keeping the source as clipboard was only ever the means. By the time a later attempt fails, its predecessor has migrated the notes onto its own file and deleted the clipboard notes file — so .clipboard names an identifier with nothing behind it, and the next re-parse (reachable straight from the search bars) reloads for session.source, finds none, and clears the note state. Obeying Decision 6 literally produces the exact silent loss Decision 6 exists to prevent.
The residual cost is real and now documented: the user asked for second.md, that file exists on disk with the content, and the app tells them the document is first.md. Worth being precise about the follow-on, because the code comments overstate it — settling ends the chain, and since Save As is gated on session.isUnsaved everywhere (both layouts and the confirmation dialog), a settled .file session simply has no Save button. There is no "next Save As behaves like an ordinary file-document save" in production; there is no next Save As. That is a stronger outcome than the test comment implies, but it does mean no in-app retry can move the notes to the intended destination.
handleSaveFailed did not clear persisted clipboard state on the settle branch. Because toPersistableState() returns nil for a file source, no later background transition could overwrite it — leaving a ghost pasted-document restore on next launch, keyed to a clipboard identifier whose notes file the chain had already deleted.previousDestination = attempt.url is recorded on the intent to migrate, before migrateNotes reports whether it actually migrated or skipped (both fold into true). The comment asserts an invariant marginally stronger than the code maintains. It is safe only because the next attempt's identity guard also fails closed — which loadedFileNotesAreNotTreatedAsThisChainsMigrationSource demonstrates. Making migrateNotes report migrated-vs-skipped would make it exact; not worth the signature churn now.previousDestination is not scoped to a session, and ClipboardSaveFlow is @State on a view that can outlive a session swap. A stale destination could carry into a new chain; the identity guard makes it harmless.completeSaveFlow, and its security-scoped bookmark has expired by then. Recorded in Decision 7's Consequences.completeSaveFlow re-derives attempt currency from URL equality rather than the PendingSave.id this change just introduced. Verified safe (the slot is overwritten per attempt, so it can never hold an older URL than the completing one, making the mismatch branch pure defence in depth), but it is approximate where an exact mechanism now exists.prism/ViewModels/ClipboardSaveFlow.swift
Why it matters. The heart of the fix, and the file where getting the async reasoning wrong reintroduces silent note loss. Two rules do all the work: a new attempt awaits its predecessor rather than cancelling it, and every step after an await is gated on session.isCurrentSaveAttempt.
What to look at. ClipboardSaveFlow.swift:77-97 (start), :113-174 (run), :195-209 (finaliseFailure)
prism/ViewModels/ClipboardSaveFlow.swift
Why it matters. The subtlest line in the change. previousDestination is restored AHEAD of the currency guard, while the user-visible finalisation sits BEHIND it. Reversing that reproduces the ticket's own bug one chain-step deeper — which is exactly what commit 9e37343 was fixing after round 2.
What to look at. ClipboardSaveFlow.swift:146-153
prism/Services/NotesManager.swift
Why it matters. The original guard — 'these notes must still carry the clipboard identifier' — is a precondition on the FIRST attempt only. A second attempt legitimately starts from the first's destination, and reading that shape as 'not mine' is what stranded the notes. Also the change's sharpest edge: its safety rests on a prose caller contract the callee cannot check.
What to look at. NotesManager.swift:970-1000 (parameter + guard), :1013 (catch-path guard), :1023-1030 (success-path guard), :1036-1042 (same-file delete guard)
specs/clipboard-notes/decision_log.md
Why it matters. The one genuinely contentious call, and the finding that upgraded this review from nits. The new branch contradicts Decision 6 and Requirement 4.5 AT SHALL LEVEL, and the divergence was recorded only in a bugfix report — which is not the spec. Anyone reading specs/clipboard-notes/ still saw the pre-fix contract.
What to look at. specs/clipboard-notes/decision_log.md (new Decision 7, Decision 6 status amended); requirements.md:58 (Req 4.5 rewritten, Req 4.7 added)
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. A real latent bug introduced by the new failure branch, found in this review. Once the session settles onto a file, toPersistableState() returns nil for it, so no later background transition can ever overwrite the clipboard entry an earlier one wrote — leaving a ghost pasted-document restore on the next launch, keyed to a clipboard identifier whose notes file this chain already deleted.
What to look at. DocumentFlowCoordinator.swift:425-446 (handleSaveFailed)
prismTests/ConsecutiveSaveAsTests.swift
Why it matters. This is what makes the change trustworthy — every guard has a test that fails when the guard is mutated away, so none of them is decoration. The review found and closed the one hole in that property.
What to look at. ConsecutiveSaveAsTests.swift (10 @Test cases); new failedFirstAttemptStillRevertsToTheClipboard at :214-243
A new attempt captures the previous task and does await previous?.value before its own work. Cancellation was rejected on two grounds: nothing on this path checks Task.isCancelled (verified through the whole call chain — NotesStore's bodies are synchronous inside an actor), and migrateNotes has already moved the in-memory note identity by its first suspension point, so half-applying it is worse than completing it.
Serialising is also load-bearing for correctness, not just safety: it is what makes each attempt's starting point knowable, which is the precondition previousDestination depends on.
pendingSaveURL looked like an identity and was not — it cannot distinguish two attempts to the same URL, which is a real shape (the user confirms an overwrite while the first save is still migrating). Consequently the view's .onChange had to move from pendingSaveURL to pendingSave as well, or a second save to the same file would never have fired.
A long-lived 'this session's notes were moved to A' flag in NotesManager would be equivalent for detecting the shape, but the manager has no way to know when a save chain ends — so it would silently change what 'export a copy' does to an already-saved document's notes. The cost of the chosen design is a caller contract (only pass a URL this chain actually migrated to) that the callee cannot enforce, since migrating from an identifier also deletes it.
An attempt taking the no-notes branch merely loads the destination file's own pre-existing notes. Handing that URL on would let the next attempt adopt — and then delete — a strand the flow never created. Pinned by loadedFileNotesAreNotTreatedAsThisChainsMigrationSource.
Residual, accepted: the recording happens on the intent to migrate, before migrateNotes reports whether it migrated or skipped (both return true). Safe only because the next attempt's guard also fails closed.
Diverges from specs/clipboard-notes/ Decision 6 and Req 4.5, both of which say the source stays clipboard on failure. Correct, because by the time a later attempt fails its predecessor has deleted the clipboard notes file, so .clipboard names an identifier with nothing behind it and the next re-parse clears the note state.
Recorded as a full decision log entry during this review, with Decision 6's status amended and Req 4.5 rewritten (it was false as written). The first attempt in a chain is unchanged — it still reverts to the clipboard, and that branch now has a test.
Applied in this review. It was inert (nothing below checks cancellation) but contradicted the design rule three lines above it, and it is the exact line that arms itself the day any notes I/O moves to a cancellation-aware API: the predecessor would be truncated after migrateNotes had already rewritten the in-memory identifier. The public cancel() used by onDisappear is kept — its doc comment already states it is advisory.
Considered switching to PendingSave.id, since this change introduces exactly that mechanism and URL equality cannot distinguish two attempts to the same file. Verified safe as-is and left alone: handleFileSave overwrites the slot on every attempt, so it can never hold an older URL than the one completing, making the mismatch branch pure defence in depth. Noted rather than churned.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | specs/clipboard-notes/ — unrecorded spec divergence | The new failure branch transitions the source from clipboard to file, contradicting Decision 6 ("if migration fails, keep the source as clipboard") and Requirement 4.5, which states the same rule at SHALL level. The divergence was argued only in the bugfix report and agent-notes, neither of which is the spec — anyone reading specs/clipboard-notes/ still saw the pre-fix contract. The repo has clear precedent for bugfixes updating decision logs when they overrule one (T-1056 → open-from-url Decision 10). | Added Decision 7 to specs/clipboard-notes/decision_log.md as a full Enhanced Nygard entry (three genuine alternatives, positive and negative consequences, impact section), amended Decision 6's Status to note the failure branch is superseded in part, rewrote Req 4.5 (which was false as written), and added Req 4.7 for the ordering-and-latest-wins rule. |
| major | DocumentFlowCoordinator.handleSaveFailed — stale persisted state | The new settle-on-a-file branch did not call clearPersistedState(). Because toPersistableState() returns nil for a file source, once the session settles no later background transition can overwrite the clipboard entry a previous one wrote. The stale entry restores a ghost copy of the pasted document on the next launch — under a clipboard identifier whose notes file an earlier attempt in the same chain already deleted. completeSaveFlow already does this clear for the success path; this is the other exit from clipboard-hood. | Added clearPersistedState() to the notesRemainAt != nil branch, with a comment explaining why the branch needs it and the success path's precedent. Verified against StatePersistenceIntegrationTests (158/158 green). |
| minor | ConsecutiveSaveAsTests — untested failure branch | The notesRemainAt == nil branch of finaliseFailure — revert to clipboard, report nil — had no test. That is the COMMON case (a single Save As whose migration fails) and the one Decision 6 and Req 4.5 actually describe; every failure test in the suite was mid-chain. Concretely, mutating the call to `notesRemainAt: migrationSource ?? attempt.url` would silently turn every first-attempt failure into a file save while still showing the clipboard-storage message, and passed the entire suite. | Added failedFirstAttemptStillRevertsToTheClipboard, asserting source == .clipboard, failures == [nil], note content still under the clipboard identifier, and nothing at the target. Mutation-verified: with the mutation applied the suite runs 10 tests, 9 pass, and the one failure is exactly this test. |
| minor | ClipboardSaveFlow.start — dead cancel() and latent trap | start() called previous?.cancel() and then immediately awaited that same task, directly contradicting the class doc comment's rule 1 ("a new attempt awaits the previous one instead of racing it"). Inert today — verified that nothing in the notes path is cancellation-sensitive — but every mid-chain attempt cancels its predecessor, so if any of that I/O moves to a cancellation-aware API (async FileManager, a retry Task.sleep, URLSession for a synced container), store.save starts throwing CancellationError, migrateNotes returns false, and every superseded attempt drives the new user-visible failure banner. Flagged independently by two reviewers. | Removed the call and replaced it with a comment recording why it must not come back. The public cancel() used by onDisappear is kept — its doc comment already describes it as advisory. |
| minor | Documentation — wrong spec cited in three places | report.md (x2) and docs/agent-notes/notes-system.md cited "clipboard-render Decision 6". The governing spec is clipboard-notes. Both specs have a Decision 6 and they mean entirely different things (clipboard-render's is about confirming before replacing unsaved content), so following the pointer lands on a real but unrelated decision — worse than a dangling reference. | Corrected all three to clipboard-notes, and the Related entry now also notes the supersession by Decision 7. |
| minor | NotesManager — stale selector in doc comment | startSignInReload's doc comment still named migrateNotes(fromClipboardSession:toFileURL:); the selector gained previousDestination: in this change. That comment is specifically the one explaining why the identifier check cannot be dropped in favour of the generation check, so a stale pointer there is worth more than usual. | Updated to the current three-part selector. |
| minor | report.md — stale counts and coverage claims | The Verification section claimed "6 tests" against a suite of nine (now ten), and the Affected Files table did not mention the spec changes. | Corrected to 10, added the new test to the pins table with its mutation, and listed the decision_log.md / requirements.md changes in Affected Files. |
| minor | DocumentFlowCoordinator.completeSaveFlow — approximate currency check | The coordinator re-derives attempt currency from URL equality (bookmark.url == url) when this change just introduced the exact mechanism for it — PendingSave.id and isCurrentSaveAttempt. prepareSave was even made @discardableResult returning the attempt, and its only production caller discards it. URL equality cannot distinguish two attempts to the same URL, a case the flow explicitly supports and tests. | Skipped after verification. handleFileSave overwrites the slot on every attempt (including to nil when bookmark creation fails), so it can never hold an older URL than the one completing — the mismatch branch is pure defence in depth and the approximation is not reachable as a bug. Recorded as a decision rather than churned. |
| nit | ClipboardSaveFlow — previousDestination recorded on intent | previousDestination = attempt.url is written before the migration and unconditionally, but migrateNotes can return true without migrating (the identity guard early-returns true when the notes belong to neither identifier). The comment asserts an invariant the code maintains only accidentally — it is safe because the NEXT attempt's guard also fails closed, which is what loadedFileNotesAreNotTreatedAsThisChainsMigrationSource demonstrates. | Skipped. Making it exact means having migrateNotes report migrated-vs-skipped rather than folding both into true — a signature change to a load-bearing method for a shape that is not reachable as a bug. Recorded in the agent-note and in this review's Potential Issues instead. |
| nit | prismTests — four hand-rolled NotesStoreProtocol doubles | HookedNotesStore and DestinationFailingNotesStore each re-implement the same four protocol bodies already in MockNotesStore, which has simulateSaveError (global rather than path-scoped). With NotesManagerLoadRaceTests.DelayedNotesStore that is four stubs of one protocol in the target. | Skipped — consolidating touches unrelated test files, which is outside a pre-push review's remit. Worth a follow-up chore. |
| nit | Misc style | saveAttemptCount is Int with += while the sibling monotonic counter parseGeneration three properties away is UInt64 with wrapping &+= and documents why; pendingSaveURL now has zero production callers (tests and docs only); the runOverlappingSaves helper comment describes an interleaving the test does not actually produce (both start() calls land synchronously before either task body runs, which is equivalent but not what the comment says). | Skipped — all cosmetic, none reachable as a defect. Int overflow at 2^63 save attempts is not a scenario. |
Click to expand.
diff --git a/prism/ViewModels/ClipboardSaveFlow.swift b/prism/ViewModels/ClipboardSaveFlow.swiftnew file mode 100644index 0000000..5271fd5--- /dev/null+++ b/prism/ViewModels/ClipboardSaveFlow.swift@@ -0,0 +1,215 @@+//+// ClipboardSaveFlow.swift+// prism+//+// Created by Claude on 15/8/2026.+//++import OSLog+import SwiftUI++private let logger = Logger.prism(category: "ClipboardSaveFlow")++/// Drives the asynchronous half of a clipboard Save As.+///+/// The exporter writes the file synchronously, but the session only becomes a+/// file document once its notes have followed it: `migrateNotes` moves the+/// clipboard-keyed notes onto the saved file's identifier (or, when there are+/// none, the file's own notes are loaded). Only then does the session+/// transition and the saved file get registered in recents (Decision 6).+///+/// The document stays unsaved for that whole window, so the Save button stays+/// live and a second Save As can arrive mid-flight. Two rules keep consecutive+/// attempts from cross-wiring (T-1812):+///+/// 1. **Attempts run in order.** A new attempt awaits the previous one instead+/// of racing it. Cancellation alone never did: nothing on this path checks+/// `Task.isCancelled`, and `migrateNotes` must not be abandoned halfway+/// anyway — it has already moved the notes in memory. Serialising also makes+/// the starting point of each attempt knowable, which is what+/// `previousDestination` below relies on.+/// 2. **Only the current attempt finalises.** Every step after an `await` is+/// gated on `session.isCurrentSaveAttempt`, so a superseded attempt cannot+/// transition the session to a destination the document has moved past, nor+/// claim the newer attempt's bookmark in `completeSaveFlow`.+///+/// Finalising covers failure too, and there it has to name the file the notes+/// are actually on rather than assuming the clipboard — see `finaliseFailure`.+@MainActor+final class ClipboardSaveFlow {+ /// The in-flight attempt's task, awaited by the next attempt.+ private var task: Task<Void, Never>?++ /// Destination an earlier attempt in this save chain *migrated this+ /// chain's notes to*.+ ///+ /// After a migrating attempt runs, the notes are keyed to *its* URL rather+ /// than to the clipboard session, so the next attempt has to migrate from+ /// there. Cleared once an attempt finalises: the notes are then back under+ /// an identity the session itself describes, and a later save is a fresh+ /// chain.+ ///+ /// The "this chain migrated them there" part is load-bearing, not+ /// descriptive. `migrateNotes` deletes the notes file it migrated *away*+ /// from, so whatever is passed as the previous destination is a file the+ /// next attempt may delete. An attempt that took the no-notes branch merely+ /// *loaded* the destination file's own pre-existing notes; passing that URL+ /// on would let the next attempt adopt a strand the flow never created and+ /// then delete it. So only the migrating branch records a destination — the+ /// no-notes branch clears it.+ ///+ /// A *failed* attempt restores the value it started from rather than+ /// clearing: the target write did not land, so the notes are still at the+ /// source. Clearing there would only be right for the first attempt in a+ /// chain; for a later one it would point the next attempt at the clipboard+ /// while the notes actually sit on an earlier attempt's URL, stranding them+ /// permanently (the T-1812 bug shape, one chain-step deeper). That restore+ /// is what a *superseded* failed attempt leaves behind. A failure that is+ /// still current goes on to finalise, and finalising always clears.+ private var previousDestination: URL?++ /// Starts the notes work for the session's current pending save.+ ///+ /// - Parameter onFailed: Called when the notes could not be moved, carrying+ /// the file they remain on, or `nil` when they remain under the clipboard+ /// identifier. The caller reports that location to the user rather than+ /// assuming the clipboard.+ func start(+ session: DocumentSession,+ notesManager: NotesManager,+ onCompleted: @escaping (URL) -> Void,+ onFailed: @escaping (URL?) -> Void+ ) {+ guard let attempt = session.pendingSave else { return }++ // Deliberately NOT `previous?.cancel()`. Cancelling the attempt we are+ // about to await would contradict rule 1 above, and while it is inert+ // today (nothing below checks `Task.isCancelled`), it is the exact+ // landmine that arms itself the day any of the notes I/O moves to a+ // cancellation-aware API: the predecessor would then be truncated+ // *after* `migrateNotes` had already rewritten the in-memory identifier.+ let previous = task+ task = Task {+ await previous?.value+ await self.run(+ attempt: attempt,+ session: session,+ notesManager: notesManager,+ onCompleted: onCompleted,+ onFailed: onFailed+ )+ }+ }++ /// Cancels the in-flight attempt (used when the reader disappears).+ ///+ /// Cancellation is advisory here: the notes work does not check for it, so+ /// an in-flight migration still runs to completion rather than leaving the+ /// notes half-moved.+ func cancel() {+ task?.cancel()+ }++ /// Awaits the in-flight attempt. Test join point.+ func drain() async {+ await task?.value+ }++ private func run(+ attempt: DocumentSession.PendingSave,+ session: DocumentSession,+ notesManager: NotesManager,+ onCompleted: @escaping (URL) -> Void,+ onFailed: @escaping (URL?) -> Void+ ) async {+ let migrationSource = previousDestination++ if notesManager.hasNotes {+ // This attempt is about to move the chain's notes onto its own URL,+ // which is where the next attempt has to pick them up. Recorded+ // ahead of the `await` and unconditionally: a superseded attempt+ // still moved them, and the next attempt is the one that needs to+ // know.+ previousDestination = attempt.url++ // Migrate clipboard notes to file identifier (Req 4.1–4.5)+ let success = await notesManager.migrateNotes(+ fromClipboardSession: session.id,+ previousDestination: migrationSource,+ toFileURL: attempt.url+ )+ if !success {+ // The target write did not land — `NotesStore.save` writes+ // atomically and everything after that write is non-throwing —+ // so the notes are still at the source, and the chain's+ // starting point is unchanged. Not the clipboard: for a later+ // attempt the source is an earlier attempt's file.+ // Unconditional, and ahead of the currency guard: this records+ // where the notes physically are, which is true whether or not+ // a newer attempt has superseded this one. Only the+ // user-visible finalisation below is gated on currency.+ previousDestination = migrationSource+ guard session.isCurrentSaveAttempt(attempt) else { return }+ finaliseFailure(+ session: session,+ notesRemainAt: migrationSource,+ onFailed: onFailed+ )+ return+ }+ } else {+ // No notes of this chain's to migrate — load the destination file's+ // own notes instead. Those are not ours: the chain never wrote+ // them, so this URL must not be handed to the next attempt as a+ // migration source (it would migrate, then delete, a strand that+ // already lived there).+ previousDestination = nil+ await notesManager.loadNotes(+ source: .file(url: attempt.url),+ sessionID: session.id,+ blocks: session.parsedBlocks+ )+ }++ guard session.isCurrentSaveAttempt(attempt) else { return }++ previousDestination = nil+ session.didSave(to: attempt.url)+ onCompleted(attempt.url)+ }++ /// Settles the session onto whichever document actually holds the notes+ /// after a migration failure.+ ///+ /// The obvious move is `revertToClipboard()`, and for the *first* attempt+ /// in a chain it is the right one: the notes never left the clipboard+ /// identifier. For a later attempt it is a lie that costs the user the+ /// notes. An earlier attempt already migrated them onto its own file and+ /// deleted the clipboard notes file, so a `.clipboard` source names an+ /// identifier with nothing behind it — and the next re-parse reloads notes+ /// for `session.source` (`DocumentReaderView`'s `parseRevision` task,+ /// reachable straight from the search bars), finds none, and clears the+ /// note state. The notes are still on disk, but the session has silently+ /// dropped them.+ ///+ /// So finalise onto `notesRemainAt` instead: that file was written by the+ /// earlier attempt and is where the notes are, which makes the session+ /// honest and the reload a no-op. `previousDestination` is cleared on both+ /// branches — the notes are once again under an identity the session itself+ /// describes, so the chain is over.+ private func finaliseFailure(+ session: DocumentSession,+ notesRemainAt: URL?,+ onFailed: @escaping (URL?) -> Void+ ) {+ previousDestination = nil+ if let notesRemainAt {+ logger.error("Clipboard note migration failed for session \(session.id, privacy: .public), settling on \(notesRemainAt.lastPathComponent, privacy: .public) where the notes are")+ session.didSave(to: notesRemainAt)+ } else {+ logger.error("Clipboard note migration failed for session \(session.id, privacy: .public), reverting to clipboard")+ session.revertToClipboard()+ }+ onFailed(notesRemainAt)+ }+}
diff --git a/prismTests/ConsecutiveSaveAsTests.swift b/prismTests/ConsecutiveSaveAsTests.swiftnew file mode 100644index 0000000..a6abe6c--- /dev/null+++ b/prismTests/ConsecutiveSaveAsTests.swift@@ -0,0 +1,446 @@+//+// ConsecutiveSaveAsTests.swift+// prismTests+//+// T-1812: Consecutive Save As operations can cross-wire note migration and+// bookmarks.+//+// A clipboard document stays unsaved while its notes migrate, so the Save+// button remains live and a second Save As can start while the first is still+// in flight. Nothing tied the asynchronous half of a save to the attempt that+// started it: the first attempt's completion could transition the session to+// its own (superseded) destination, the second attempt's migration saw notes+// that no longer carried the clipboard identifier and silently skipped —+// stranding them on the first destination — and the coordinator's single+// bookmark slot could be handed to whichever URL completed first.+//+// Expected behaviour: only the latest attempt may change the session source,+// move the notes, or add a recent entry, and a recent entry's bookmark always+// belongs to the URL it is labelled with.+//++import Foundation+import SwiftUI+import Testing+@testable import prism++/// A store that runs a hook inside `save`, so a test can land other work while+/// a migration is suspended in the store.+private actor HookedNotesStore: NotesStoreProtocol {+ var storedNotes: [String: DocumentNotes] = [:]+ var isAvailable: Bool { true }+ private var onSave: (@Sendable () async -> Void)?++ func setOnSave(_ hook: @escaping @Sendable () async -> Void) {+ onSave = hook+ }++ func load(for identifier: DocumentIdentifier) async -> DocumentNotes? {+ storedNotes[identifier.path]+ }++ func save(_ notes: DocumentNotes) async throws {+ if let hook = onSave {+ onSave = nil+ await hook()+ }+ storedNotes[notes.identifier.path] = notes+ }++ func delete(for identifier: DocumentIdentifier) async {+ storedNotes.removeValue(forKey: identifier.path)+ }+}++/// A store whose `save` fails for one destination only, so a test can fail a+/// single attempt in the middle of a save chain and let the rest succeed.+private actor DestinationFailingNotesStore: NotesStoreProtocol {+ var storedNotes: [String: DocumentNotes] = [:]+ var isAvailable: Bool { true }+ private var failingPath: String?++ func setFailingPath(_ path: String?) {+ failingPath = path+ }++ func load(for identifier: DocumentIdentifier) async -> DocumentNotes? {+ storedNotes[identifier.path]+ }++ func save(_ notes: DocumentNotes) async throws {+ if notes.identifier.path == failingPath {+ throw CocoaError(.fileWriteNoPermission)+ }+ storedNotes[notes.identifier.path] = notes+ }++ func delete(for identifier: DocumentIdentifier) async {+ storedNotes.removeValue(forKey: identifier.path)+ }+}++@Suite("Consecutive Save As", .serialized)+@MainActor+struct ConsecutiveSaveAsTests {++ private let firstURL = URL(fileURLWithPath: "/Users/test/first.md")+ private let secondURL = URL(fileURLWithPath: "/Users/test/second.md")+ private let thirdURL = URL(fileURLWithPath: "/Users/test/third.md")++ // MARK: - Helpers++ private func makeStructure(from blocks: [MarkdownBlock]) -> DocumentStructure {+ MarkdownSectionBuilder.build(from: blocks)+ }++ /// A clipboard session with one note already stored under the clipboard+ /// identifier, i.e. the state a Save As starts from.+ private func makeSessionWithClipboardNote(+ store: any NotesStoreProtocol+ ) async -> (session: DocumentSession, manager: NotesManager) {+ let session = DocumentSession(clipboardContent: "# Pasted")+ let block = MarkdownBlock.paragraph(markdown: "Pasted")+ session.parsedBlocks = [block]++ let manager = NotesManager.makeForTesting(store: store)+ await manager.createNote(+ content: "Clipboard note",+ for: block,+ sourceIndex: 0,+ in: makeStructure(from: [block]),+ source: .clipboard,+ sessionID: session.id+ )+ return (session, manager)+ }++ /// Runs two overlapping Save As attempts: the second is requested before+ /// the first attempt's notes work has finished.+ private func runOverlappingSaves(+ session: DocumentSession,+ manager: NotesManager,+ onCompleted: @escaping (URL) -> Void = { _ in },+ onFailed: @escaping (URL?) -> Void = { _ in }+ ) async {+ let flow = ClipboardSaveFlow()++ session.prepareSave(to: firstURL)+ flow.start(session: session, notesManager: manager, onCompleted: onCompleted, onFailed: onFailed)++ // The exporter's second callback lands while the first attempt is still+ // suspended in the notes store.+ session.prepareSave(to: secondURL)+ flow.start(session: session, notesManager: manager, onCompleted: onCompleted, onFailed: onFailed)++ await flow.drain()+ }++ // MARK: - Regression++ @Test("the notes follow the last save destination, not the first")+ func notesFollowTheFinalDestination() async {+ let store = MockNotesStore()+ let (session, manager) = await makeSessionWithClipboardNote(store: store)+ let resolver = DocumentIdentifierResolver()+ let clipboardPath = resolver.resolve(forClipboardSession: session.id).path++ await runOverlappingSaves(session: session, manager: manager)++ // Bug: the second attempt's migration saw the first attempt's file+ // identifier instead of the clipboard one, skipped, and left the notes+ // on `firstURL` while the document became `secondURL`.+ #expect(manager.documentNotes?.identifier == resolver.resolve(from: secondURL))+ // The note itself has to arrive, not just a record under that key: an+ // emptied DocumentNotes would satisfy a `!= nil` proxy while the note+ // it is supposed to carry was lost.+ #expect(await store.storedNotes[resolver.resolve(from: secondURL).path]?.notes+ .map(\.content) == ["Clipboard note"])+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path] == nil)+ #expect(await store.storedNotes[clipboardPath] == nil)+ }++ @Test("a superseded attempt cannot transition the session to its own destination")+ func supersededAttemptDoesNotTransitionTheSession() async {+ let store = MockNotesStore()+ let (session, manager) = await makeSessionWithClipboardNote(store: store)++ var completions: [URL] = []+ await runOverlappingSaves(session: session, manager: manager) { completions.append($0) }++ #expect(session.source == .file(url: secondURL))+ #expect(session.pendingSaveURL == nil)+ // Bug: both attempts finalised, so the first attempt could report a+ // completion for a destination the document had already moved past.+ #expect(completions == [secondURL])+ }++ @Test("a superseded attempt with no notes cannot transition the session either")+ func supersededAttemptWithoutNotesDoesNotTransitionTheSession() async {+ let store = MockNotesStore()+ let session = DocumentSession(clipboardContent: "# Pasted")+ session.parsedBlocks = [.paragraph(markdown: "Pasted")]+ let manager = NotesManager.makeForTesting(store: store)++ var completions: [URL] = []+ await runOverlappingSaves(session: session, manager: manager) { completions.append($0) }++ #expect(session.source == .file(url: secondURL))+ #expect(completions == [secondURL])+ }++ @Test("re-saving to the same destination keeps the notes there")+ func repeatedSaveToTheSameDestinationKeepsNotes() async {+ let store = MockNotesStore()+ let (session, manager) = await makeSessionWithClipboardNote(store: store)+ let resolver = DocumentIdentifierResolver()+ let flow = ClipboardSaveFlow()++ // The exporter reports the same destination twice (e.g. the user+ // confirms an overwrite while the first save is still migrating).+ session.prepareSave(to: firstURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { _ in })+ session.prepareSave(to: firstURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { _ in })+ await flow.drain()++ // The second migration's source and target are the same file, so it has+ // nothing to retire — deleting the "old" notes would delete the ones it+ // just wrote.+ #expect(manager.documentNotes?.identifier == resolver.resolve(from: firstURL))+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes+ .map(\.content) == ["Clipboard note"])+ }++ @Test("the first attempt in a chain still reverts to the clipboard when it fails")+ func failedFirstAttemptStillRevertsToTheClipboard() async {+ let store = DestinationFailingNotesStore()+ let (session, manager) = await makeSessionWithClipboardNote(store: store)+ let resolver = DocumentIdentifierResolver()+ let clipboardPath = resolver.resolve(forClipboardSession: session.id).path++ await store.setFailingPath(resolver.resolve(from: firstURL).path)++ let flow = ClipboardSaveFlow()+ var failures: [URL?] = []++ // One attempt only, so there is no earlier destination holding the+ // notes. This is the case clipboard-notes Decision 6 / Req 4.5 were+ // written for, and the one the mid-chain settle must NOT swallow:+ // settling here would claim a file that never received the notes.+ session.prepareSave(to: firstURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ await flow.drain()++ #expect(session.source == .clipboard)+ #expect(failures == [nil])+ // The notes never left the clipboard identifier, so that is where they+ // still have to be — and the session still names it.+ #expect(await store.storedNotes[clipboardPath]?.notes+ .map(\.content) == ["Clipboard note"])+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path] == nil)+ #expect(manager.documentNotes?.identifier == resolver.resolve(forClipboardSession: session.id))+ }++ @Test("a failed attempt mid-chain leaves the notes findable by the next attempt")+ func failedMigrationMidChainKeepsTheNotesReachable() async {+ let store = DestinationFailingNotesStore()+ let (session, manager) = await makeSessionWithClipboardNote(store: store)+ let resolver = DocumentIdentifierResolver()++ // Only the second destination's write fails, so attempt 1 succeeds+ // (moving the notes to firstURL) and attempt 2 reverts them there.+ await store.setFailingPath(resolver.resolve(from: secondURL).path)++ let flow = ClipboardSaveFlow()+ var failures: [URL?] = []++ // Three attempts, each landing while the previous is still in flight.+ // Attempt 1 moves the notes to firstURL; attempt 2's write to secondURL+ // fails; attempt 3 is the only one that finalises.+ for url in [firstURL, secondURL, thirdURL] {+ session.prepareSave(to: url)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ }+ await flow.drain()++ // Bug: the superseded failing attempt reset the chain's starting point+ // to the clipboard, so attempt 3's migration guard matched neither the+ // clipboard identifier nor firstURL, reported "skipped" as success, and+ // finalised the session onto thirdURL with the notes stranded on+ // firstURL. The failed attempt has to hand on where the notes actually+ // are — its own source — even though it is no longer current.+ #expect(session.source == .file(url: thirdURL))+ #expect(manager.documentNotes?.identifier == resolver.resolve(from: thirdURL))+ #expect(await store.storedNotes[resolver.resolve(from: thirdURL).path]?.notes+ .map(\.content) == ["Clipboard note"])+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path] == nil)+ // A superseded attempt reports nothing: attempt 3 succeeded, so there+ // is no failure for the user to see.+ #expect(failures.isEmpty)+ }++ @Test("a failed attempt mid-chain settles the session on the file holding the notes")+ func failedMigrationMidChainSettlesOnTheNotesFile() async {+ let store = DestinationFailingNotesStore()+ let (session, manager) = await makeSessionWithClipboardNote(store: store)+ let resolver = DocumentIdentifierResolver()+ let clipboardPath = resolver.resolve(forClipboardSession: session.id).path++ await store.setFailingPath(resolver.resolve(from: secondURL).path)++ let flow = ClipboardSaveFlow()+ var failures: [URL?] = []++ session.prepareSave(to: firstURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ session.prepareSave(to: secondURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ await flow.drain()++ // Attempt 1 already migrated the notes onto firstURL and deleted the+ // clipboard notes file, so "they remain in clipboard storage" is not+ // just imprecise — there is nothing under that identifier any more.+ #expect(await store.storedNotes[clipboardPath] == nil)+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes+ .map(\.content) == ["Clipboard note"])++ // Bug: the session reverted to .clipboard, which names an identifier+ // with nothing behind it. The next re-parse (reachable from the search+ // bars) reloads notes for session.source, finds none, and clears the+ // note state — losing them from the session while they sit on disk+ // under firstURL. Settle on the file that actually holds them instead,+ // and report that file rather than the clipboard.+ #expect(session.source == .file(url: firstURL))+ #expect(failures == [firstURL])++ // The reload that used to drop the notes is now a no-op that finds them.+ await manager.loadNotes(source: session.source, sessionID: session.id, blocks: session.parsedBlocks)+ #expect(manager.documentNotes?.notes.map(\.content) == ["Clipboard note"])++ // Settling ends the chain: the notes are back under an identity the+ // session itself names, so a further Save As is an ordinary file-document+ // save — it copies the content and leaves the notes with firstURL, the+ // same as saving a copy of any already-saved document.+ session.prepareSave(to: thirdURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ await flow.drain()++ #expect(session.source == .file(url: thirdURL))+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes+ .map(\.content) == ["Clipboard note"])+ #expect(await store.storedNotes[resolver.resolve(from: thirdURL).path] == nil)+ }++ @Test("an attempt that only loaded a file's own notes does not hand them to the next attempt")+ func loadedFileNotesAreNotTreatedAsThisChainsMigrationSource() async {+ let store = MockNotesStore()+ let resolver = DocumentIdentifierResolver()+ let session = DocumentSession(clipboardContent: "# Pasted")+ let block = MarkdownBlock.paragraph(markdown: "Pasted")+ session.parsedBlocks = [block]+ let manager = NotesManager.makeForTesting(store: store)++ // firstURL already has notes of its own — nothing to do with this+ // clipboard session, which has none.+ let existing = DocumentNotes(+ identifier: resolver.resolve(from: firstURL),+ displayName: firstURL.lastPathComponent,+ notes: [BlockNote(+ blockId: block.id,+ contextQuote: "Pasted",+ content: "Pre-existing note",+ status: .active,+ createdAt: Date(),+ modifiedAt: Date()+ )]+ )+ await store.preload(existing)++ // Attempt 1 saves over firstURL and, having no notes to migrate, adopts+ // that file's own. It is superseded before finalising, so attempt 2 runs+ // with those adopted notes in memory.+ await runOverlappingSaves(session: session, manager: manager)++ // Bug: attempt 1 passed firstURL on as a migration source even though+ // this chain never wrote it, so attempt 2 accepted the pre-existing+ // notes as its own, moved them to secondURL and deleted firstURL's+ // notes file — a strand the flow never created.+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes+ .map(\.content) == ["Pre-existing note"])+ #expect(await store.storedNotes[resolver.resolve(from: secondURL).path] == nil)+ }++ @Test("a load for another document during migration keeps that document's identity")+ func migrationDoesNotRebindIdentityOfADocumentLoadedMidFlight() async {+ let store = HookedNotesStore()+ let session = DocumentSession(clipboardContent: "# Pasted")+ let block = MarkdownBlock.paragraph(markdown: "Pasted")+ session.parsedBlocks = [block]+ let manager = NotesManager.makeForTesting(store: store)+ await manager.createNote(+ content: "Clipboard note",+ for: block,+ sourceIndex: 0,+ in: makeStructure(from: [block]),+ source: .clipboard,+ sessionID: session.id+ )++ // While the migration is parked in the store, the reader moves to+ // another document and its notes load lands.+ let otherURL = URL(fileURLWithPath: "/Users/test/other.md")+ await store.setOnSave { [manager] in+ await manager.loadNotes(source: .file(url: otherURL), sessionID: UUID(), blocks: [])+ }++ let migrated = await manager.migrateNotes(+ fromClipboardSession: session.id,+ toFileURL: firstURL+ )++ #expect(migrated == true)+ // The migration wrote its notes, but must not rebind the manager's+ // cached identity onto a document the reader has left.+ #expect(manager.cachedDocumentIdentifier == DocumentIdentifierResolver().resolve(from: otherURL))+ }++ @Test("a recent entry never carries another destination's bookmark")+ func recentEntryBookmarkMatchesItsURL() throws {+ let flow = DocumentFlowCoordinator()+ let suiteName = UUID().uuidString+ let defaults = try #require(UserDefaults(suiteName: suiteName))+ defaults.removePersistentDomain(forName: suiteName)+ let recentFiles = RecentFilesManager(storage: defaults)+ flow.setDocumentServicesForTests(DocumentServices(+ recentFilesManager: recentFiles,+ persistedSessionData: .constant(Data()),+ bundledDocumentState: BundledDocumentState(defaults: defaults)+ ))++ let directory = FileManager.default.temporaryDirectory+ .appendingPathComponent("t1812-\(UUID().uuidString)", isDirectory: true)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ defer { try? FileManager.default.removeItem(at: directory) }++ let first = directory.appendingPathComponent("first.md")+ let second = directory.appendingPathComponent("second.md")+ try Data("# First".utf8).write(to: first)+ try Data("# Second".utf8).write(to: second)++ flow.currentSession = DocumentSession(clipboardContent: "# Pasted")++ // Two exporter callbacks land back to back; the second replaces the+ // bookmark slot.+ flow.handleFileSave(.success(first))+ flow.handleFileSave(.success(second))++ // A stale finalisation for the first destination must not claim the+ // second destination's bookmark.+ flow.completeSaveFlow(first)++ let entry = try #require(recentFiles.recentFiles.first { $0.fileName == "first.md" })+ let resolved = try recentFiles.withRecentFile(entry) { $0 }+ #expect(resolved.lastPathComponent == "first.md",+ "the entry labelled first.md resolved to \(resolved.lastPathComponent)")+ }+}
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex 67882ce..cc6695c 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -33,12 +33,32 @@ final class DocumentSession: Identifiable { /// constant to preserve view identity. private(set) var source: DocumentSource - /// Pending save destination while clipboard note migration is in progress.+ /// One Save As attempt: its destination plus the identity that+ /// distinguishes it from every other attempt on this session.+ ///+ /// The document stays unsaved while its notes migrate, so a second Save As+ /// can start while the first is still in flight. The `id` is what lets the+ /// asynchronous half of a save tell "I am still the current attempt" from+ /// "a newer attempt has superseded me" — including when both attempts+ /// target the same URL (T-1812).+ struct PendingSave: Equatable, Sendable {+ /// Monotonic per session; never reused.+ let id: Int+ let url: URL+ }++ /// Pending save attempt while clipboard note migration is in progress. /// /// Set after the system writes the exported file, but before the /// source transitions to `.file(url:)`. The transition only completes /// after note migration succeeds (Decision 6).- private(set) var pendingSaveURL: URL?+ private(set) var pendingSave: PendingSave?++ /// Destination of the pending save attempt, if any.+ var pendingSaveURL: URL? { pendingSave?.url }++ /// Counter behind `PendingSave.id`.+ private var saveAttemptCount = 0 /// The raw markdown content. ///@@ -435,8 +455,24 @@ final class DocumentSession: Identifiable { /// note migration succeeds (Decision 6). /// /// - Parameter url: The URL where the file was exported.- func prepareSave(to url: URL) {- pendingSaveURL = url+ /// - Returns: The attempt that now owns the save flow. Each call supersedes+ /// the previous attempt, whose completion must no longer be applied.+ @discardableResult+ func prepareSave(to url: URL) -> PendingSave {+ saveAttemptCount += 1+ let attempt = PendingSave(id: saveAttemptCount, url: url)+ pendingSave = attempt+ return attempt+ }++ /// Whether `attempt` is still the session's current save attempt.+ ///+ /// Everything a save attempt applies after an `await` — the source+ /// transition, the recents entry, the migration-failure revert — is gated+ /// on this, so a superseded attempt cannot finalise over a newer one+ /// (T-1812).+ func isCurrentSaveAttempt(_ attempt: PendingSave) -> Bool {+ pendingSave == attempt } /// Updates the source after successful save and note migration.@@ -456,7 +492,7 @@ final class DocumentSession: Identifiable { func didSave(to url: URL) { source = .file(url: url) fileObserver = FileChangeObserver(fileURL: url)- pendingSaveURL = nil+ pendingSave = nil } /// Reverts the source to clipboard after a failed note migration.@@ -467,7 +503,7 @@ final class DocumentSession: Identifiable { func revertToClipboard() { source = .clipboard fileObserver = nil- pendingSaveURL = nil+ pendingSave = nil } /// Parses the document content into blocks.
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex d30d910..3c081e5 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -182,7 +182,8 @@ final class NotesManager { /// being the newest generation, win. `loadGeneration` is monotonic, so /// comparing it retires such a reload for good. The identifier is still /// checked because one path — the clipboard→file migration in- /// `migrateNotes(fromClipboardSession:toFileURL:)` — moves the cached+ /// `migrateNotes(fromClipboardSession:previousDestination:toFileURL:)` —+ /// moves the cached /// identity without starting a load, so the generation would still match /// there. private func startSignInReload() {@@ -967,14 +968,32 @@ final class NotesManager { /// - Requirements: 4.1–4.5 /// - Parameters: /// - sessionID: The clipboard session whose notes should be migrated.+ /// - previousDestination: Destination that an earlier, superseded Save As+ /// on the same session **migrated these notes to**, if any. That attempt+ /// already moved them off the clipboard identifier, so this one migrates+ /// from there instead (T-1812). The clipboard identifier is always+ /// accepted too, since the earlier attempt may have reverted.+ ///+ /// Caller contract, not a hint: migrating from an identifier also+ /// *deletes* it, and nothing here can tell notes an earlier attempt put+ /// at that URL from notes that already lived there. So only pass a URL+ /// this save chain actually migrated to — never one whose own notes were+ /// merely loaded (`ClipboardSaveFlow`'s no-notes branch), or this will+ /// adopt and then delete a strand the flow never created. /// - url: The file URL the document was saved to. /// - Returns: `true` if migration succeeded or there were no notes to migrate.- func migrateNotes(fromClipboardSession sessionID: UUID, toFileURL url: URL) async -> Bool {+ func migrateNotes(+ fromClipboardSession sessionID: UUID,+ previousDestination: URL? = nil,+ toFileURL url: URL+ ) async -> Bool { guard var notes = documentNotes else { return true } - // Validate that loaded notes match the expected clipboard session+ // Validate that loaded notes match the expected clipboard session, or+ // the destination a superseded attempt on this session moved them to. let expectedIdentifier = identifierResolver.resolve(forClipboardSession: sessionID)- guard notes.identifier == expectedIdentifier else {+ let supersededIdentifier = previousDestination.map { identifierResolver.resolve(from: $0) }+ guard notes.identifier == expectedIdentifier || notes.identifier == supersededIdentifier else { logger.info("migrateNotes skipped — loaded notes (\(notes.identifier.path, privacy: .public)) don't match expected clipboard session (\(expectedIdentifier.path, privacy: .public))") return true }@@ -992,13 +1011,21 @@ final class NotesManager { try await store.save(notes) } catch { logger.error("Failed to save migrated notes: \(error, privacy: .public)")- // Revert in-memory state+ // Revert in-memory state — but only if it is still ours to revert.+ guard documentNotes?.identifier == targetIdentifier else { return false } notes.identifier = sourceIdentifier notes.displayName = sourceDisplayName documentNotes = notes return false } + // The save is a suspension point: another migration or a load for a+ // different document may have replaced the in-memory notes while this+ // one was parked in the store. Everything below rebinds document-wide+ // identity, so it must only run while these notes are still the ones+ // this migration moved (T-1812).+ guard documentNotes?.identifier == targetIdentifier else { return true }+ // Update cached document identity so imported-note resolved toggles // (which write to ImportedNoteResolvedCache keyed by cachedDocumentPath) // are scoped to the saved file, not the old clipboard session (T-839).@@ -1006,8 +1033,12 @@ final class NotesManager { cachedDocumentIdentifier = targetIdentifier documentPath = targetDisplayName - // Delete the old clipboard notes file (orphan on failure is accepted — Decision 5)- await store.delete(for: sourceIdentifier)+ // Delete the old notes file (orphan on failure is accepted — Decision 5).+ // Re-saving to the same destination has nothing to retire and must not+ // delete the file just written.+ if sourceIdentifier != targetIdentifier {+ await store.delete(for: sourceIdentifier)+ } return true }
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex 79503bd..5abc1b1 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -58,7 +58,12 @@ final class DocumentFlowCoordinator { /// Bookmark data created eagerly during file export callback, before /// security-scoped access expires. Used by `completeSaveFlow` to register /// the saved file in recent files.- private var pendingSaveBookmarkData: Data?+ ///+ /// The URL it was created for travels with it: consecutive Save As+ /// operations reuse this single slot, so a finalisation must prove the+ /// bookmark is its own before turning it into a recent entry — otherwise an+ /// entry labelled with one destination can resolve to another (T-1812).+ private var pendingSaveBookmark: (url: URL, data: Data)? // MARK: - Injected Dependencies @@ -367,7 +372,7 @@ final class DocumentFlowCoordinator { // access from the file exporter callback. Deferring this to // completeSaveFlow (after async note migration) would fail because the // temporary access grant expires when the callback returns.- pendingSaveBookmarkData = Self.createBookmarkData(for: url)+ pendingSaveBookmark = Self.createBookmarkData(for: url).map { (url, $0) } currentSession?.prepareSave(to: url) case .failure(let error):@@ -383,18 +388,19 @@ final class DocumentFlowCoordinator { func completeSaveFlow(_ url: URL) { clearPersistedState() - if let bookmarkData = pendingSaveBookmarkData {- pendingSaveBookmarkData = nil+ if let bookmark = pendingSaveBookmark, bookmark.url == url {+ pendingSaveBookmark = nil documentServices?.recentFilesManager.addSavedFile( url: url,- bookmarkData: bookmarkData,+ bookmarkData: bookmark.data, title: currentSession?.cachedDocumentTitle ) } else { // Fallback: try direct bookmark creation (may work in some environments).- // This path means the eager bookmark in handleFileSave failed — log it so- // the failure is visible during development and testing.- logger.warning("completeSaveFlow: pendingSaveBookmarkData was nil for \(url.lastPathComponent, privacy: .public), falling back to direct bookmark creation")+ // This path means the eager bookmark in handleFileSave failed, or the+ // stored one belongs to a different destination — log it so the+ // failure is visible during development and testing.+ logger.warning("completeSaveFlow: no pending bookmark for \(url.lastPathComponent, privacy: .public), falling back to direct bookmark creation") documentServices?.recentFilesManager.addSavedFile(url: url, title: currentSession?.cachedDocumentTitle) } @@ -406,14 +412,38 @@ final class DocumentFlowCoordinator { /// Handles a failed clipboard note migration after save. ///- /// Clears pending bookmark data and notifies the user that notes could- /// not be migrated.- func handleSaveFailed() {- pendingSaveBookmarkData = nil+ /// Clears pending bookmark data and tells the user where the notes actually+ /// are. For the first attempt in a save chain that is the clipboard, but a+ /// later attempt starts from an earlier attempt's file — the clipboard notes+ /// file is already gone by then, so claiming the notes "remain in clipboard+ /// storage" would send the user looking somewhere they no longer exist+ /// (T-1812).+ ///+ /// - Parameter notesRemainAt: The file the notes are still attached to —+ /// which the flow has settled the session onto — or `nil` when they remain+ /// under the clipboard identifier.+ func handleSaveFailed(notesRemainAt: URL? = nil) {+ pendingSaveBookmark = nil pendingAction = nil- migrationError = String(- localized: "Your document was saved, but notes could not be migrated and remain in clipboard storage."- )+ if let notesRemainAt {+ // The session is now a file document, so `toPersistableState()`+ // returns nil for it and a background transition can never+ // overwrite the clipboard state a previous one wrote. Left behind,+ // that entry restores a ghost copy of the pasted document on the+ // next launch — under a clipboard identifier whose notes file an+ // earlier attempt in this chain already deleted. `completeSaveFlow`+ // clears it for the success path; this branch is the other way the+ // session stops being a clipboard document (T-1812).+ clearPersistedState()+ let fileName = notesRemainAt.lastPathComponent+ migrationError = String(+ localized: "Your notes could not be moved to the file you just saved, so the document is still open as \(fileName), where its notes are."+ )+ } else {+ migrationError = String(+ localized: "Your document was saved, but notes could not be migrated and remain in clipboard storage."+ )+ } } /// Opens a bundled markdown document by resource name.
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex 43db527..074b69a 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -57,8 +57,10 @@ struct DocumentReaderView: View { /// Called when a clipboard save fully completes (including note migration). let onSaveCompleted: (URL) -> Void - /// Called when clipboard note migration fails during save.- let onSaveFailed: () -> Void+ /// Called when clipboard note migration fails during save, carrying the+ /// file the notes remain on (`nil` when they remain under the clipboard+ /// identifier).+ let onSaveFailed: (URL?) -> Void /// Called when a link tap resolves to an in-app navigation target. var onOpenLink: ((ResolvedLink) -> Void)?@@ -70,8 +72,8 @@ struct DocumentReaderView: View { /// and the sidebar's notes view. @State private var notesManager = NotesManager() - /// Task for loading notes, stored to allow cancellation when session changes.- @State private var loadNotesTask: Task<Void, Never>?+ /// Drives the asynchronous note work behind a clipboard Save As.+ @State private var saveFlow = ClipboardSaveFlow() /// Left sidebar visibility state, initialized from settings. @State private var leftSidebarVisible = true@@ -299,36 +301,22 @@ struct DocumentReaderView: View { // Handle pending clipboard save after file export succeeds (Decision 6). // Migration runs BEFORE source transitions to .file, preventing notes // from entering an unreachable state.- .onChange(of: session.pendingSaveURL) { _, pendingURL in- guard let url = pendingURL else { return }-- loadNotesTask?.cancel()-- loadNotesTask = Task {- if notesManager.hasNotes {- // Migrate clipboard notes to file identifier (Req 4.1–4.5)- let success = await notesManager.migrateNotes(- fromClipboardSession: session.id,- toFileURL: url- )- if !success {- logger.error("Clipboard note migration failed for session \(session.id, privacy: .public), reverting to clipboard")- session.revertToClipboard()- onSaveFailed()- return- }- } else {- // No clipboard notes to migrate, load file notes- await notesManager.loadNotes(source: .file(url: url), sessionID: session.id, blocks: session.parsedBlocks)- }-- session.didSave(to: url)- onSaveCompleted(url)- }+ // Keyed on the attempt rather than the destination: two Save As+ // operations to the same URL are still two attempts, and the+ // second must run its own notes work (T-1812).+ .onChange(of: session.pendingSave) { _, pending in+ guard pending != nil else { return }++ saveFlow.start(+ session: session,+ notesManager: notesManager,+ onCompleted: onSaveCompleted,+ onFailed: onSaveFailed+ ) } // Cancel any pending task when view disappears .onDisappear {- loadNotesTask?.cancel()+ saveFlow.cancel() } // Initialize sidebar visibility from persisted settings .onAppear {@@ -613,7 +601,7 @@ extension EnvironmentValues { onClose: {}, onSave: {}, onSaveCompleted: { _ in },- onSaveFailed: {}+ onSaveFailed: { _ in } ) } .environment(AppSettings())@@ -633,7 +621,7 @@ extension EnvironmentValues { onClose: {}, onSave: {}, onSaveCompleted: { _ in },- onSaveFailed: {}+ onSaveFailed: { _ in } ) } .environment(AppSettings())@@ -650,7 +638,7 @@ extension EnvironmentValues { onClose: {}, onSave: {}, onSaveCompleted: { _ in },- onSaveFailed: {}+ onSaveFailed: { _ in } ) } .environment(AppSettings())
diff --git a/prism/prismApp.swift b/prism/prismApp.swiftindex 59b76fe..a9c6f43 100644--- a/prism/prismApp.swift+++ b/prism/prismApp.swift@@ -288,8 +288,8 @@ struct MainContentView: View { onClose: { flowCoordinator.requestClose() }, onSave: { flowCoordinator.isFileExporterPresented = true }, onSaveCompleted: { url in flowCoordinator.completeSaveFlow(url) },- onSaveFailed: {- flowCoordinator.handleSaveFailed()+ onSaveFailed: { notesRemainAt in+ flowCoordinator.handleSaveFailed(notesRemainAt: notesRemainAt) }, onOpenLink: { resolved in handleResolvedLink(resolved)
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex 2031a1d..af181fa 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -6004,6 +6004,29 @@ } } },+ "Your notes could not be moved to the file you just saved, so the document is still open as %@, where its notes are.": {+ "extractionState": "manual",+ "localizations": {+ "en": {+ "stringUnit": {+ "state": "translated",+ "value": "Your notes could not be moved to the file you just saved, so the document is still open as %@, where its notes are."+ }+ },+ "en-GB": {+ "stringUnit": {+ "state": "translated",+ "value": "Your notes could not be moved to the file you just saved, so the document is still open as %@, where its notes are."+ }+ },+ "en-US": {+ "stringUnit": {+ "state": "translated",+ "value": "Your notes could not be moved to the file you just saved, so the document is still open as %@, where its notes are."+ }+ }+ }+ }, "Zoom In": { "extractionState": "manual", "localizations": {
diff --git a/specs/clipboard-notes/decision_log.md b/specs/clipboard-notes/decision_log.mdindex 62b68da..f932511 100644--- a/specs/clipboard-notes/decision_log.md+++ b/specs/clipboard-notes/decision_log.md@@ -173,7 +173,7 @@ Clipboard note files are small (JSON with a few notes). The accumulation rate is ## Decision 6: Migration Before Source Transition **Date**: 2026-02-24-**Status**: accepted+**Status**: accepted; the failure branch is superseded in part by Decision 7 ### Context @@ -202,3 +202,50 @@ This ordering ensures notes are never left in an unreachable state. The source t - Save operation may appear to fail even though the file was written successfully (the file is saved but the source stays as clipboard) ---++## Decision 7: A Failed Migration Settles on Whichever Document Holds the Notes++**Date**: 2026-08-15+**Status**: accepted (supersedes the failure branch of Decision 6)++### Context++Decision 6 fixed the ordering of a single save: migrate the notes, then transition the source, and on failure keep the source as `.clipboard`. Requirement 4.5 states the same rule at SHALL level. Both were written for a save chain of length one, which was the only shape the design modelled.++T-1812 showed the chain can be longer. A clipboard document counts as unsaved for the whole migration window, so the Save button stays live and a second Save As can start while the first is still moving the notes. `ClipboardSaveFlow` now serialises those attempts, which means a later attempt legitimately starts from an earlier attempt's file rather than from the clipboard.++That breaks Decision 6's failure branch. By the time a *later* attempt fails, the earlier one has already migrated the notes onto its own file **and deleted the clipboard notes file**. `revertToClipboard()` then names an identifier with nothing behind it, and the next re-parse — reachable straight from the search bars — reloads notes for `session.source`, finds none, and clears the note state. The notes survive on disk but are silently lost from the session: the exact failure mode Decision 6 exists to prevent, arrived at by obeying Decision 6.++### Decision++On a failed migration, settle the session on whichever document actually holds the notes. For the first attempt in a chain that is still the clipboard, so `revertToClipboard()` and the existing "notes remain in clipboard storage" message are unchanged. For a later attempt it is the earlier attempt's file: the flow calls `session.didSave(to:)` on that file and passes its URL to `onFailed`, so the user is told which file the document is now open as and where its notes are.++### Rationale++The invariant Decision 6 was protecting is "notes are never left unreachable", not "the source stays clipboard" — the latter was only ever the means. When the clipboard identifier is empty, keeping the source pointed at it *is* the unreachable state. Naming the file that holds the notes keeps the invariant and makes the subsequent reload a no-op instead of a silent clear.++The user does lose the destination they last chose: they asked for `second.md`, that file exists on disk with the content, and the app tells them the document is `first.md`. That is the honest trade — the alternative keeps their chosen filename and loses their annotations.++### Alternatives Considered++- **Keep reverting to the clipboard (Decision 6 as written)**: Simplest, and preserves the stated mental model — Rejected because for a later attempt it names an identifier whose file an earlier attempt already deleted, so the next re-parse clears the note state. It converts a recoverable write failure into silent note loss.+- **Finalise on the newly-saved destination and report where the notes stayed**: Honours the filename the user actually chose — Rejected because the notes then live under a document identity nothing in the session points at. It is the T-1812 bug shape with a message attached, and the next reload still clears them.+- **Keep the chain alive so a further Save As retries the migration**: Would let the user reach their intended destination after a transient failure — Rejected because the session is a file document once it settles, and a chain that outlives that would make an ordinary "export a copy" of an already-saved document move its notes, which is a different and larger behaviour change (the same reasoning that keeps `previousDestination` out of `NotesManager`).++### Consequences++**Positive:**+- The notes are always reachable from `session.source` after a failure, so the reload that used to drop them finds them+- The failure message names a real file rather than storage that no longer exists+- The first-attempt case — the common one, and the one Decision 6 and Req 4.5 describe — is unchanged++**Negative:**+- The document ends up named after a file the user only transiently chose, while the file they last asked for sits on disk with the content but no notes+- Settling ends the save chain, and a file document has no Save button, so there is no in-app retry that moves the notes to the intended destination+- The settled file is not added to Recent Files: the attempt that wrote it was superseded, so it never reached `completeSaveFlow`++### Impact++`ClipboardSaveFlow.finaliseFailure`, `DocumentFlowCoordinator.handleSaveFailed(notesRemainAt:)` (which also clears the persisted clipboard state, since the session stops being a clipboard document on this branch), `DocumentReaderView.onSaveFailed`, and Requirement 4.5. Pinned by `failedFirstAttemptStillRevertsToTheClipboard` and `failedMigrationMidChainSettlesOnTheNotesFile` in `prismTests/ConsecutiveSaveAsTests.swift`.++---
diff --git a/specs/clipboard-notes/requirements.md b/specs/clipboard-notes/requirements.mdindex 4333863..c850cce 100644--- a/specs/clipboard-notes/requirements.md+++ b/specs/clipboard-notes/requirements.md@@ -55,8 +55,9 @@ Prism currently disables the notes feature for documents opened via clipboard pa 2. <a name="4.2"></a>The system SHALL delete the session-based notes file after successful migration 3. <a name="4.3"></a>IF the target file already has notes, the system SHALL overwrite them with the clipboard notes 4. <a name="4.4"></a>The system SHALL complete note migration before transitioning the document source from clipboard to file, so that migration failure does not leave notes unreachable-5. <a name="4.5"></a>IF migration fails (e.g., write error), the system SHALL retain the session-based notes, keep the document source as clipboard, and log the failure+5. <a name="4.5"></a>IF migration fails (e.g., write error), the system SHALL retain the notes where they were before the attempt, log the failure, and set the document source to whichever document still holds them — the clipboard for the first save attempt, or the file an earlier attempt in the same save chain migrated them to (Decision 7). The failure message SHALL name that file when it is not the clipboard 6. <a name="4.6"></a>The system SHALL complete migration without requiring user interaction+7. <a name="4.7"></a>WHEN a second Save As starts while an earlier one is still migrating, the system SHALL run the attempts in order and SHALL apply only the latest attempt's outcome to the document source, the recent-files entry, and the note location (T-1812) ---
diff --git a/specs/bugfixes/consecutive-save-as-cross-wiring/report.md b/specs/bugfixes/consecutive-save-as-cross-wiring/report.mdnew file mode 100644index 0000000..bf1f90c--- /dev/null+++ b/specs/bugfixes/consecutive-save-as-cross-wiring/report.md@@ -0,0 +1,238 @@+# Bugfix Report: Consecutive Save As Cross-Wires Note Migration and Bookmarks++**Date:** 2026-08-15+**Status:** Fixed+**Ticket:** T-1812++## Description of the Issue++A pasted (clipboard) document stays *unsaved* while its notes migrate: the+exporter writes the file, `prepareSave(to:)` records the destination, and the+session only becomes `.file(url:)` once `migrateNotes` has moved the notes onto+the saved file's identifier. The Save button is driven by `session.isUnsaved`,+so it stays live for that whole window and a second Save As can start while the+first is still in flight.++Nothing tied the asynchronous half of a save to the attempt that started it, so+two attempts could interleave and cross-wire:++- The notes were stranded on the *first* destination. The second attempt's+ `migrateNotes` validated that the loaded notes still carried the clipboard+ identifier; the first attempt had already rewritten it to its own file+ identifier, so the second attempt logged "skipped" and returned `true`. The+ document became `second.md` while its notes lived under `first.md` — invisible+ in the reader, and left behind if the first file was deleted.+- A superseded attempt could still finalise. `session.didSave(to:)` and+ `onSaveCompleted(url)` ran unconditionally after the awaits, so the first+ attempt could transition the session to a destination the document had already+ moved past.+- A recent-files entry could resolve to the wrong file. `pendingSaveBookmarkData`+ is a single coordinator slot, overwritten by each exporter callback, and+ `completeSaveFlow(url)` consumed whatever was in it — producing an entry+ labelled `first.md` that resolved to `second.md`.++**Reproduction steps:**+1. Paste a document and add a note to it.+2. Save As to `first.md`.+3. Before note migration finishes, Save As again to `second.md`.+4. Observe: the document is `second.md`, but its notes are stored under+ `first.md` and no longer appear; recents can hold an entry labelled+ `first.md` that opens `second.md`.++**Impact:** High. Silent note loss from the user's point of view (the notes are+still on disk, but under a document identity nothing points at any more), plus a+recents entry that opens the wrong file. Requires a fast second Save As, so it+is rare but wholly user-reachable.++## Investigation Summary++- **Symptoms examined:** the ticket's evidence trail — the Save button's+ visibility rule, the single bookmark slot, the unchecked `Task` cancellation in+ `DocumentReaderView`, and `migrateNotes`' lack of any post-`await` check.+- **Code inspected:** `DocumentReaderView.onChange(of: session.pendingSaveURL)`,+ `DocumentFlowCoordinator.handleFileSave` / `completeSaveFlow` /+ `handleSaveFailed`, `DocumentSession.prepareSave` / `didSave` /+ `revertToClipboard`, `NotesManager.migrateNotes`, and the T-1811 generation+ machinery in `NotesManager.loadNotes` / `startSignInReload` /+ `noteContainer(for:)` (plus `docs/agent-notes/notes-system.md`).+- **Hypotheses tested:**+ - *Cancellation is enough.* Ruled out: nothing on this path checks+ `Task.isCancelled`, and the first attempt has already mutated the in-memory+ note identity by its first suspension point — abandoning it halfway is worse+ than letting it finish.+ - *Only the finalisation needs guarding.* Ruled out: gating just+ `didSave`/`onSaveCompleted` leaves the second migration looking at notes that+ no longer carry the clipboard identifier, so it still skips and strands them.+ - *Gate the UI instead (hide Save while a save is pending).* Rejected as the+ primary fix — see Alternatives.++## Discovered Root Cause++**Defect type:** Race condition — unidentified asynchronous work.++A Save As is a two-phase operation (write file → move notes → transition+session), but only the first phase had an identity. Every piece of state the+second phase touched was single-slotted and unversioned: one `pendingSaveURL`,+one `pendingSaveBookmarkData`, one in-memory note identifier. Two attempts+therefore shared one set of slots, and each step after an `await` acted on+whatever it found there rather than on what its own attempt had put there.++`migrateNotes` compounded it: its only guard was "the loaded notes must still+carry the clipboard identifier", which is a *precondition on the first attempt+only*. A second attempt in the same save chain legitimately starts from the+first attempt's destination, and that shape was read as "these notes aren't+mine" and silently skipped.++**Why it occurred:** the clipboard→file migration was designed as a one-shot+transition (clipboard-notes Decision 6), so a second attempt was never modelled.+The window it needs is created by the very design that keeps notes safe — the+source deliberately stays `.clipboard` until migration succeeds — which also+keeps the Save button live.++## Resolution for the Issue++**Changes made:**++- `prism/Models/DocumentSession.swift` — `pendingSaveURL` became+ `pendingSave: PendingSave?`, an attempt with a monotonic per-session `id`+ (`pendingSaveURL` remains as a derived accessor). Added+ `isCurrentSaveAttempt(_:)`. The id distinguishes attempts even when both+ target the same URL.+- `prism/ViewModels/ClipboardSaveFlow.swift` (new) — the save flow lifted out of+ `DocumentReaderView` into a testable `@MainActor` type. Two rules: attempts run+ **in order** (a new attempt awaits the previous one instead of racing it), and+ **only the current attempt finalises** (every step after an `await` is gated on+ `session.isCurrentSaveAttempt`). It also carries `previousDestination` — the+ URL the preceding attempt **migrated the notes to** — so the next attempt knows+ where to pick them up. Only the migrating branch records one: migrating from an+ identifier also deletes it, and an attempt that merely loaded a destination+ file's own notes must not let the next attempt adopt and then delete a strand+ the flow never created. A failed attempt restores the value it started from+ (nothing landed at the target, so the notes are still at the source);+ finalising always clears it.+- `ClipboardSaveFlow.finaliseFailure` settles the session on whichever document+ actually holds the notes. `revertToClipboard()` is honest only for the first+ attempt in a chain — a later attempt's predecessor already migrated the notes+ onto its own file and deleted the clipboard notes file, so `.clipboard` would+ name an identifier with nothing behind it and the next re-parse would clear the+ note state. A later attempt calls `session.didSave(to:)` on that file instead,+ and passes its URL to `onFailed` so the user is told where the notes are.+- `prism/Views/DocumentReaderView.swift` — observes `session.pendingSave` and+ delegates to `ClipboardSaveFlow`; `loadNotesTask` is gone.+- `prism/Services/NotesManager.swift` — `migrateNotes` takes+ `previousDestination:` and accepts notes carrying either the clipboard+ identifier or that destination; re-checks `documentNotes?.identifier` after the+ store save before rebinding cached document identity (or reverting on+ failure); and skips the source delete when source and target are the same file.+- `prism/ViewModels/DocumentFlowCoordinator.swift` — `pendingSaveBookmarkData`+ became `pendingSaveBookmark: (url, data)`; `completeSaveFlow` uses it only when+ its URL matches, otherwise falls back to direct bookmark creation.++**Approach rationale:** the guards follow T-1811's shape — identity captured+before the first `await`, re-checked after every suspension, with the write-side+check (`documentNotes?.identifier == targetIdentifier`) placed where the damage+would otherwise be done. Serialising attempts is what makes the chain's starting+point knowable: without it, "where are the notes right now?" has no answer a+later attempt can rely on, and no amount of generation-checking recovers it.++**Alternatives considered:**++- **Hide/disable Save while a save is pending.** Removes the overlap window, but+ only for the paths that go through the button: it is a UI-layer fix for a+ state-layer race, and it makes a stuck migration lock the user out of saving+ entirely. Worth doing as polish; not enough as the fix.+- **A migration generation counter in `NotesManager`.** Equivalent for detecting+ supersession, but a second monotonic counter next to `loadGeneration` for a+ path that is now serialised buys nothing the in-memory identifier check does+ not already give.+- **Long-lived migration provenance in `NotesManager`** (remember that this+ session's notes were moved to A). Rejected: it silently changes what+ "export a copy" does to an already-saved document's notes. Passing the previous+ destination in from the flow that owns the chain keeps that behaviour untouched.++## Regression Test++**Test file:** `prismTests/ConsecutiveSaveAsTests.swift`++| Test | Pins |+|------|------|+| `notesFollowTheFinalDestination` | the notes end on the last destination, with nothing left at the first or under the clipboard identifier |+| `supersededAttemptDoesNotTransitionTheSession` | only the current attempt calls `didSave`/`onSaveCompleted` |+| `supersededAttemptWithoutNotesDoesNotTransitionTheSession` | same, on the no-notes (`loadNotes`) branch |+| `repeatedSaveToTheSameDestinationKeepsNotes` | re-saving to the same file does not delete the notes just written |+| `failedFirstAttemptStillRevertsToTheClipboard` | the single-attempt failure still keeps the source as clipboard and reports no file (Decision 6 / Req 4.5 unchanged for the first attempt) |+| `failedMigrationMidChainKeepsTheNotesReachable` | a superseded failing attempt hands on its own source, so the next attempt still finds and moves the notes |+| `failedMigrationMidChainSettlesOnTheNotesFile` | a current failing attempt mid-chain settles the session on the file holding the notes and reports that file, instead of claiming clipboard storage that no longer exists |+| `loadedFileNotesAreNotTreatedAsThisChainsMigrationSource` | an attempt that only loaded a file's own notes does not let the next one migrate — and delete — a pre-existing strand |+| `migrationDoesNotRebindIdentityOfADocumentLoadedMidFlight` | a migration parked in the store cannot rebind cached identity onto a document loaded while it was suspended |+| `recentEntryBookmarkMatchesItsURL` | a recent entry resolves to the file it is labelled with |++Each was checked by mutation: removing the current-attempt guard fails the three+session tests, removing the `previousDestination` acceptance fails the notes+test, resetting `previousDestination` to `nil` on failure fails the mid-chain+reachability test, reverting to the clipboard on a mid-chain failure fails the+settle test, recording the no-notes branch's URL as a migration source fails the+pre-existing-strand test, removing the same-file delete guard fails the+repeat-save test, removing the post-save identity check fails the mid-flight-load+test, settling on a file when there is no earlier destination (`notesRemainAt ??+attempt.url`) fails the first-attempt revert test, and the bookmark test failed+against the pre-fix coordinator.++**Run command:**++```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' -testPlan prism \+ -only-test-configuration "en (base)" \+ -only-testing:prismTests/ConsecutiveSaveAsTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Models/DocumentSession.swift` | `PendingSave` attempt identity; `isCurrentSaveAttempt` |+| `prism/ViewModels/ClipboardSaveFlow.swift` | New: serialised, attempt-gated save flow |+| `prism/Views/DocumentReaderView.swift` | Delegates the save flow; observes `pendingSave` |+| `prism/Services/NotesManager.swift` | `previousDestination`, post-await identity guard, same-file delete guard |+| `prism/ViewModels/DocumentFlowCoordinator.swift` | URL-keyed pending bookmark; clears persisted clipboard state when a failure settles on a file |+| `prismTests/ConsecutiveSaveAsTests.swift` | New: regression suite |+| `specs/clipboard-notes/decision_log.md`, `requirements.md` | Decision 7 (supersedes Decision 6 in part); Req 4.5 amended |+| `CHANGELOG.md`, `docs/agent-notes/notes-system.md` | Documentation |++## Verification++**Automated:**++- [x] Regression tests pass (10 tests, both locale runs)+- [x] Targeted sweep passes: all `NotesManager*` clipboard/load/sign-in suites,+ `SaveFlowIntegrationTests`, `DocumentSessionTests`,+ `ConfirmationDialogIntegrationTests`, `DocumentFlowCoordinatorRecentFileTests`+- [x] `make lint` — 0 violations+- [x] `make build-macos` and `make build-ios` succeed+- [ ] Full suite — deferred to the merge step (CI is billing-blocked; several fix+ agents were running concurrently on this machine)++**Manual verification:** not performed — the race needs a second Save As inside+the migration window, which the tests reproduce deterministically and a human+cannot reliably hit.++## Prevention++- Asynchronous work started from view state needs an identity when the state it+ finalises can change underneath it. `pendingSaveURL` looked like an identity+ and was not: it cannot tell two attempts to the same URL apart.+- A precondition written for the first call of an operation ("these notes must+ still be clipboard notes") is not a precondition for the second. When an+ operation can legitimately repeat, its accepted starting states must include+ the state its own previous run produced.+- Prefer serialising a short chain of user-initiated async operations over+ cancelling them, when the operation mutates shared state before its first+ suspension point. Cancellation there is advisory at best and half-applied work+ at worst.++## Related++- T-1811 (PR #359) — the load/reload generation guards this fix is modelled on+- `docs/agent-notes/notes-system.md` — Clipboard Notes Support / Migration on Save+- `specs/clipboard-notes/` — Decision 6 (migrate before the source transitions), superseded in part by Decision 7
diff --git a/docs/agent-notes/notes-system.md b/docs/agent-notes/notes-system.mdindex d471d09..d582180 100644--- a/docs/agent-notes/notes-system.md+++ b/docs/agent-notes/notes-system.md@@ -79,10 +79,38 @@ Notes work for all document source types. Clipboard identifiers use the format ` ### Migration on Save -When a clipboard document is saved to a file, `DocumentReaderView.onChange(of: session.source)` triggers migration:-1. If clipboard notes exist: `migrateNotes(fromClipboardSession:toFileURL:)` updates the identifier in-memory, saves to the file-based key, and deletes the clipboard key+When a clipboard document is saved to a file, `DocumentReaderView.onChange(of: session.pendingSave)` hands the attempt to `ClipboardSaveFlow` (`prism/ViewModels/`), which:+1. If clipboard notes exist: `migrateNotes(fromClipboardSession:previousDestination:toFileURL:)` updates the identifier in-memory, saves to the file-based key, and deletes the old key 2. If no clipboard notes: loads file notes normally-3. On migration failure: `session.revertToClipboard()` restores clipboard source so notes remain accessible+3. On migration failure: the flow settles the session on whichever document actually holds the notes — `session.revertToClipboard()` only for the *first* attempt in a chain (see below); a later attempt calls `session.didSave(to:)` on the earlier attempt's file+4. On success: `session.didSave(to:)` plus `DocumentFlowCoordinator.completeSaveFlow` (recents entry)++### Consecutive Save As (T-1812)++The source deliberately stays `.clipboard` until migration succeeds (clipboard-notes Decision 6), which is also what keeps `isUnsaved` — and therefore the Save button — live for the whole migration. A second Save As can start inside that window, and the transition was designed as one-shot, so nothing distinguished the two attempts. Three things now do:++- **`DocumentSession.PendingSave`** carries a monotonic per-session `id` alongside the URL. `pendingSaveURL` is derived from it. The id, not the URL, is the identity: two attempts to the *same* file are still two attempts, and the view keys its `onChange` on `pendingSave` so the second one runs.+- **`ClipboardSaveFlow` serialises attempts and gates finalisation.** A new attempt awaits the previous one rather than cancelling it — cancellation was never load-bearing here (nothing on this path checks `Task.isCancelled`, and `migrateNotes` has already moved the in-memory identity by its first suspension point), and serialising is what makes "where are the notes right now?" answerable. Every step after an `await` is gated on `session.isCurrentSaveAttempt`, so a superseded attempt cannot `didSave` to a destination the document has moved past or claim the newer attempt's bookmark.+- **`previousDestination` travels with the migration.** `migrateNotes`' guard — "these notes must still carry the clipboard identifier" — is a precondition on the *first* attempt only. The second legitimately starts from the first's destination, and reading that as "not mine" is what stranded the notes on the first file. The flow owns the chain and passes the previous destination in; the clipboard identifier stays accepted too, in case the earlier attempt reverted. Deliberately *not* long-lived state inside `NotesManager`: remembering "this session's notes were moved to A" indefinitely would silently make exporting a copy of an already-saved document move its notes, which is a different behaviour change.++Three rules govern `previousDestination`, and none of them is guessable from the field's name:++- **Only a destination this chain *migrated* to may be passed on.** Migrating from an identifier also deletes it, and `migrateNotes` cannot tell notes an earlier attempt put at a URL from notes that already lived there. An attempt taking the no-notes branch merely *loads* the destination file's own notes, so it clears the field rather than recording its URL — otherwise a superseded no-notes attempt would let the next one adopt a pre-existing strand and then delete the file the flow never created. Only the migrating branch records.+- **A failed attempt restores the value it started from, unconditionally and ahead of the currency guard.** `NotesStore.save` writes atomically and everything after that write is non-throwing (the T-241 cleanup contract), so a `false` return guarantees nothing landed at the target and the notes are still at the source. Clearing to `nil` there is right only for the *first* attempt in a chain; for a later one it points the next attempt at the clipboard while the notes sit on an earlier attempt's URL — the T-1812 bug shape, one chain-step deeper. It runs ahead of the currency guard because a superseded failing attempt still has to hand on where the notes are.+- **Finalising always clears it**, on success and on failure alike: the notes are then under an identity `session.source` itself names, so the chain is over.++**A failed migration must not claim the clipboard.** `revertToClipboard()` is honest only when this is the first attempt in the chain. For a later attempt the earlier one already migrated the notes onto its own file *and deleted the clipboard notes file*, so `.clipboard` names an identifier with nothing behind it — and `DocumentReaderView`'s `parseRevision` task reloads notes for `session.source` on every re-parse (reachable straight from the search bars), finds none, and clears the note state. The notes survive on disk and are silently lost from the session. `ClipboardSaveFlow.finaliseFailure` therefore calls `session.didSave(to:)` on the file that actually holds them, and `onFailed` carries that URL so `DocumentFlowCoordinator.handleSaveFailed(notesRemainAt:)` names the file instead of asserting clipboard storage. That is a deliberate divergence from clipboard-notes Decision 6 / Req 4.5, recorded as **Decision 7** — read it before "fixing" the branch back to an unconditional revert.++Two consequences of settling that are easy to misread, both in Decision 7's Consequences:++- **Settling genuinely ends the chain.** Save As is gated on `session.isUnsaved` (`RegularDocumentLayout`, `CompactBottomToolbar`, and the unsaved-confirmation dialog all check it), and the settled session is `.file`, so the Save button disappears — there is no in-app retry that moves the notes to the destination the user actually asked for. `failedMigrationMidChainSettlesOnTheNotesFile` drives a further `prepareSave` directly to pin what *would* happen; production cannot reach it through the UI.+- **`handleSaveFailed` clears the persisted clipboard state on this branch.** `toPersistableState()` returns nil for a file source, so once the session settles, a background transition can never overwrite the clipboard entry an earlier one wrote. Left behind it restores a ghost copy of the pasted document on the next launch, under a clipboard identifier whose notes file this chain already deleted. `completeSaveFlow` does the same clear for the success path.++Two smaller guards inside `migrateNotes`: it re-checks `documentNotes?.identifier == targetIdentifier` after the store save before rebinding cached document identity (a load for another document can land while it is parked in the store — the T-1811 failure shape), and it skips the source delete when source and target are the same file (re-saving to the same destination would otherwise delete the notes it just wrote).++`DocumentFlowCoordinator.pendingSaveBookmark` carries the URL its bookmark was created for; `completeSaveFlow` uses it only on a match, so a recents entry can never resolve to a different file than the one it names.++Regression coverage: `prismTests/ConsecutiveSaveAsTests.swift`, one test per guard (all mutation-checked). ### View Layer
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9938073..3261ac8 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Saving a pasted document twice in quick succession no longer leaves its notes behind on the first file (T-1812). A pasted document counts as unsaved until its notes have followed it to the file you saved it as, and the Save button stays available for that moment — so choosing **Save** again straight away started a second save while the first was still moving the notes. The second one found the notes already carrying the first file's name, decided they were not its to move, and quietly left them there: the document became the second file while its notes stayed with the first, where nothing showed them and deleting that file would have taken them with it. The first save could also finish afterwards and hand the document back to the file you had just saved past, and the entry it added to Recent Files could open the other file entirely. Consecutive saves now run one after another and only the last one counts: the notes follow the document to wherever you finally saved it, the earlier attempt can no longer take the document back, and a Recent Files entry always opens the file it names. Saving twice to the same file keeps the notes there rather than removing them. A save whose notes could not be moved still tells you so, and now tells you where they are: the first such save leaves the document pasted as before, but a later one hands the document back to the file that already holds its notes and names that file, instead of pointing you at clipboard storage the earlier save had already cleared out. - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.
After a mid-chain failure settles the session onto first.md, Save As is gone: it is gated on session.isUnsaved in both layouts and the confirmation dialog, and the session is now .file. So there is no in-app way to move the notes to the destination the user actually asked for, and second.md sits on disk with the content but no notes.
I judge this correct — it prefers reachable notes over an honoured filename, after a disk-level failure, and the alert names the file. But it is a genuine product call, now recorded in Decision 7's Consequences. Note the code comments and the test comment overstate the follow-on as "the next Save As is an ordinary file-document save"; production has no such path, because a file document has no Save button. The agent-note now says so.
Three full make test-quick runs produced three different failing sets: {WebScrollabilityReporting, WebScrollNavigation}, then {WebScrollabilityReporting}, then {DocumentSessionScrollPersistence, WebScrollabilityReporting}. Failure durations of 21–22s are timeouts. Every one of them passes in isolation — 25/25 for the two WebKit classes, 23/23 for the scroll pair, 158/158 for the targeted save-flow sweep.
Other agent sessions were building on the same machine throughout (one run aborted outright on a locked build.db). This diff touches no WebKit or scroll-persistence code. Re-run these on an idle machine before merge if you want a clean full-suite number.
migrateNotes cannot tell notes an earlier attempt put at a URL from notes that already lived there, and migrating from an identifier also deletes it. So the safety of the whole mechanism rests on ClipboardSaveFlow being the only caller that passes a non-nil value, and only from the migrating branch.
That is the sharpest edge in the change. It is well documented in three places and has the most test coverage in the suite, which is the right mitigation — but any future second caller of migrateNotes needs to read that contract before passing anything.
Validated locally with the full flag set (-testPlan prism -only-test-configuration "en (base)" -parallel-testing-worker-count 1), read through Tools/check-test-results.sh against the result bundle rather than an exit code, using target/class identifiers throughout. make lint clean at 0 violations across 530 files; make build-macos and make build-ios both succeed with no warnings from any touched file. Code signing was not overridden.
Localisation validation runs as a build phase (Tools/validate-localisation.py), so the new catalog key passed as part of both platform builds.