Second pre-push review. Round one judged the shipped control flow correct and every finding was prose or tests; commit 2778dc31 (now 46c74062) answered them. This round verifies the answers by mutation rather than by reading, and audits the prose the fix commit itself added.
History note: after this review's own fixes landed, the branch was rebased onto origin/main and extended by one further commit. Every SHA below is post-rebase. The final tree was re-built, re-linted and re-tested against that rebase — exit 0, 71/71 — so the verdict stands on the tree as it is now, not as it was when the review started.
id comparison: 71 run, 69 pass, and the 2 failures are the 2 new collision tests. The centrepiece of round two does what it says.===. PendingSaveBookmark.belongs(to:) carries the same reference comparison and no test separates it from an id comparison — the bookmark test's replacement has a fresh id, so an id-keyed belongs still rejects it and the suite stays green.clear(_:ownedBy:)'s comment says the content file “belongs to this session alone”, and its own residual paragraph, two paragraphs later, describes exactly the case where it does not — and where the unconditional delete takes the colliding session's pasted text. The residual is recorded at half its severity.NotesManager being @State per reader. Nothing in the branch says so. Added to the agent note — along with the half of the id collision that reaches NotesStore, which no document mentions.NotesManager registers a process-wide NSUbiquityIdentityDidChange observer that can flip iCloudAvailable to false, and loadNotes then calls clearNoteState() outright — which is precisely the symptom observed.clear(_:ownedBy:) is 8 lines under 40 lines of comment, several of them addressed to a reviewer rather than to a maintainer.46c74062, 18fa987c) carry <<<<<<< markers in CHANGELOG.md; the tip clears them. Harmless under squash-merge, wrong under bisect or a merge commit. Not fixed — see findings.Ready to push
The discriminator pinning is real. I mutated both guards to currentSession?.id == session.id, rebuilt, and ran the full six-suite set on an isolated derived-data path and a dedicated simulator: exit 65, total 71 passed 69 failed 2, and the two failures are exactly the two new collision tests. Dropping clear(_:ownedBy:)'s ownership check reproduces the reported four failures. Both mutation claims in the report hold as written.
What round two did not close is the claim-accuracy problem — it moved it. Three false statements were corrected; two more were introduced in their place, both inside the fix commit's own prose, and one of them contradicts a paragraph two paragraphs below it in the same doc comment. I corrected those, plus four prose findings round one raised that were left standing, and recorded the one load-bearing fact both reviews found living only in a reviewer's head.
The branch then moved under the review. It was rebased onto origin/main and given a sixth commit that fixes finding #11 correctly. I re-built, re-linted and re-ran the six-suite set against the rebased tree: exit 0, 71/71, lint clean. The rebase left conflict markers in CHANGELOG.md in two mid-branch commits; the tip commit clears them and the final tree is correct. That is the one item I did not touch — rewriting history while another process was rebasing the same branch is how work gets lost.
Everything else remaining is trimming and test-fixture hygiene, listed as skipped. None of it blocks a push.
883e8672 Fix T-2213: Late clipboard Save As finalisation mutates a replacement session 1fb79f74 T-2213 review: record the residual limit of id-keyed ownership 3114ae07 T-2213 review: correct my own claim about what narrows the id collision 46c74062 T-2213 review: pin the discriminator, repair a vacuous test, correct three claims 18fa987c T-2213 review 2: correct two claims the fix commit introduced, record two facts 8fbd585e T-2213 pre-push: drop a vacuous assertion that read like coverage Nothing about how the app behaves. The previous review said the fix itself was right but that several sentences describing it were wrong, and that the tests did not actually check the one thing the fix turns on. This round is about whether those two complaints were properly answered.
The fix has to tell two documents apart. There are two ways to do that: compare the documents themselves, or compare their identity numbers. The code compares the documents. But every test used two documents with different identity numbers, so both ways of comparing gave the same answer — the tests could not tell whether the code was doing it the careful way or the sloppy way.
Two new tests build the one situation where the two ways disagree: a document restored from a saved session, which keeps its old identity number. I checked these by deliberately changing the code to the sloppy way and running the tests. Exactly those two failed. So the check is now genuinely held in place.
The descriptions. Three wrong sentences were corrected, but two new wrong ones were written while correcting them — including one in the note that users read, and one that contradicts a paragraph a few lines below it. I have fixed those.
LateSaveFinalisationTests gained two tests that mint an id collision through installReplacementDocument(sessionID:) → StatePersistence.save → restorePersistedSession() → DocumentSession(persisted:), which reuses the stored UUID verbatim. Under currentSession?.id == session.id both fail; under === both pass. Confirmed by rebuilding and running, not by reading.lateCompletionKeepsTheReplacementRestorePoint now calls fixture.persistRestorePoint(for:) first and asserts the content file exists before the save, so “its own restore point went” is an observation rather than a tautology.clear(_:ownedBy:) tests. Own slot, another session's slot, empty slot, undecodable data.The method does two things: delete the session's content file (unconditional) and wipe the shared scene binding (conditional on the binding's metadata naming that session). Conflating them would be the dangerous regression, and the suite does separate them — clearOwnedByLeavesAnotherSessionsSlotIntact and clearOwnedByLeavesUndecodableDataAlone each assert “content file gone, binding untouched”, so a future edit that makes the wipe unconditional fails both. The reverse direction — moving the content delete inside the guard — fails three of the four. One assertion is dead weight: clearOwnedByHandlesAnEmptySlot's #expect(storageData.isEmpty) is checking a variable that was initialised empty.
The fix introduces three reference comparisons, not two. PendingSaveBookmark.belongs(to:) uses self.session === session && self.attempt == attempt, and no test separates that === from an id comparison: lateCompletionDoesNotConsumeTheReplacementsBookmark gives the replacement a fresh id, so an id-keyed belongs would reject it just the same. That test does pin the attempt half (both attempts are id 1 to the same URL, so dropping the session check entirely fails it), but not the reference half.
both guards -> currentSession?.id == session.id
XCODEBUILD_EXIT=65 total 71 passed 69 failed 2
FAIL lateCompletionSeparatesADocumentThatReusedTheSavingSessionsID
FAIL lateFailureSeparatesADocumentThatReusedTheSavingSessionsID
clear(_:ownedBy:) ownership check deleted
XCODEBUILD_EXIT=65 total 71 passed 67 failed 4
FAIL clearOwnedByLeavesAnotherSessionsSlotIntact
FAIL clearOwnedByLeavesUndecodableDataAlone
FAIL lateCompletionKeepsTheReplacementRestorePoint
FAIL lateMidChainFailureClearsOnlyTheSavingDocumentsRestorePoint
baseline
XCODEBUILD_EXIT=0 total 71 passed 71 failed 0All three on -derivedDataPath /tmp/T2213-review-DD against a purpose-created simulator (4D297607), -parallel-testing-worker-count 1, counts read from the result bundle and reconciled against xcodebuild's exit status.
They pin the guard. The collision is constructed once, in the fixture, but the assertions are on coordinator state that only the guard controls: pendingAction != nil after a completion, and migrationError == nil after a failure. Any weakening that lets the guard admit a different object — id comparison, != nil, deletion — fires the deferred action and fails. The positive direction is pinned elsewhere and was not regressed: DocumentFlowCoordinatorRecentFileTests.saveCompletionExecutesPendingBookmarkOpen asserts pendingAction == nil after an installed session's completion, so an always-false guard is caught too.
What the fixture does constrain is realism. restorePersistedSession() has exactly one production caller — prismApp.swift:456, at window creation, before any save can be in flight — so the collision the tests build is, by the branch's own argument, unreachable in production today. That is an honest trade (the alternative is no pinning at all), but it means the two tests defend a discriminator against a hazard the same document argues cannot occur. Worth stating in the test file, which currently states only the mechanism.
clear(_:ownedBy:) ownership-checks the binding and not the content file. The doc comment justifies the asymmetry with “it belongs to this session alone”, then two paragraphs later describes the id collision that makes that false. In the collision case the ownership check does not merely match the wrong binding — SessionFileManager.deleteContent(for:) never asks at all, so it takes the colliding session's pasted text. That is the same unrecoverable loss T-2213 exists to stop, surviving inside the fix for it. It is bounded by the same one-caller argument, but the comment presents the content file as the safe half when it is the unchecked one.
The collision also reaches a store no document mentions. NotesManager.migrateNotes ends with store.delete(for: sourceIdentifier) where the source is clipboard/{sessionID} — so a colliding restored session's notes file is deleted by another session's migration, with no guard anywhere on that path. Same bound, same class, not written down.
The investigation eliminated cross-suite leakage through NotesManager.loadGeneration (correctly — private instance var) and concluded no shared state could carry it. There is shared state, of a different kind: NotesManager.init unconditionally calls observeiCloudChanges(), registering a process-wide NotificationCenter observer for .NSUbiquityIdentityDidChange whose handler calls checkiCloudAvailability() → FileManager.default.ubiquityIdentityToken != nil, which is nil on a simulator. That sets iCloudAvailable = false, and the very first line of loadNotes after the cache update is guard iCloudAvailable else { clearNoteState(); return }. clearNoteState() sets documentNotes = nil — which is precisely what failed at ConsecutiveSaveAsTests.swift:324. makeForTesting sets the flag directly, bypassing applyiCloudAvailability, so nothing re-arms it. Timing-dependent, invisible in isolation, consistent with “under load”, and outside every mechanism the note considered.
prismTests/LateSaveFinalisationTests.swift
Why it matters. This is what round one asked for and the only part of round two that can be verified rather than read. It works: mutating both guards to an id comparison fails exactly these two out of 71.
What to look at. LateSaveFinalisationTests.swift:348-406, and installReplacementDocument(sessionID:) at :140-149
prism/Services/StatePersistence.swift
Why it matters. The comment defends the unconditional content-file delete with 'it belongs to this session alone', then describes the id collision that falsifies exactly that. In the collision case the delete takes the other session's pasted text — the loss this ticket exists to stop.
What to look at. StatePersistence.swift:120-133
prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. Round two pinned the two guards and stopped. The same reference comparison lives in the bookmark ownership test and no test separates it from an id comparison — the bookmark test's replacement carries a fresh id, so both spellings reject it.
What to look at. DocumentFlowCoordinator.swift:80-82
prismTests/StatePersistenceTests.swift
Why it matters. The failure mode worth guarding is a future edit that makes the binding wipe unconditional — which is the pre-fix behaviour and the data loss. Two of the four tests assert 'content file gone, binding untouched', so that edit cannot pass.
What to look at. StatePersistenceTests.swift:229-313
CHANGELOG.md
Why it matters. Round one asked for the unqualified 'still gets its Recent Files entry' to be qualified. The replacement says the entry 'is now left out altogether rather than added as a link that opens the wrong file' — neither half holds.
What to look at. CHANGELOG.md:23
The fixture mints the id collision through the app's own restore path rather than by constructing two DocumentSessions with a shared id directly. That keeps the test honest about how the collision could arise, at the cost of exercising a path that in production runs only at window creation — i.e. never while a save is in flight. The trade is right, but the test file argues the mechanism and not the reachability.
On the replaced-session failure path migrationError is never set, so the user is told nothing: the file was written, the notes did not follow it, and no recents entry is added on that path either. Round one asked for this to be either surfaced differently or recorded as deliberate. It is now implied by the blanket session-scoped rule in the agent note but never stated as a choice with a consequence.
Three shared slots were identity-scoped (bookmark, persisted state, recents title); the fourth was not. completeSaveFlow's comment asserts that after a replacement “whatever sits in the slot belongs to the replacement's own flow”. That is true, but only because every replacement route reaches the coordinator through the unsaved-confirmation handlers, which either clear the slot (handleDiscardAndProceed, handleFileImport) or overwrite it. Nothing states that dependency, and activateSession itself touches pendingAction nowhere.
Round one deliberately left the tree untouched because the findings were sentences in the author's voice. That produced a fix round which corrected three claims and introduced two. The claims corrected here are mechanical — each is a statement the code plainly contradicts — so they are edited and committed rather than handed back for a third pass. Judgement calls (trims, fixture dedup, extra tests) are left as findings.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | StatePersistence.swift:120-133 — the comment contradicts its own residual | The doc comment justifies deleting the content file unconditionally with "it belongs to this session alone", and its own residual paragraph two paragraphs later describes the id collision where it does not. In that case the delete never asks at all, so it takes the colliding session's pasted text — the same unrecoverable loss T-2213 exists to stop, surviving inside the fix for it. The residual is written as if only the binding match were affected, which records a data-loss case at half its severity. | Rewrote both paragraphs: the binding is the half that can belong to someone else and is checked; the content file is unconditional and is the half the collision costs the most. Added the sibling collision in NotesStore (see below). |
| major | CHANGELOG.md:23 — the qualification overcorrected | Round one asked for the unqualified "still gets its Recent Files entry" to be qualified. The replacement asserts the entry "is now left out altogether rather than added as a link that opens the wrong file". Neither half holds. The pre-fix code matched the bookmark slot on URL equality (the T-1812 guard), so the entry it produced always named and resolved to the same file — it could not open the wrong one. And post-fix the fallback still calls addSavedFile(url:title:), which succeeds whenever RecentFileEntry(url:) can mint a bookmark, so the entry is often present. | Restated as what actually changed: the late save no longer takes over the shortcut the other document had prepared, which can leave the saved file without an entry of its own. Added that the file is saved either way. |
| minor | DocumentFlowCoordinator.swift:62-76 — per-app vs per-window (raised in round one, left standing) | "this single slot is reused by every Save As in the app" and "names one Save As in the app's life". DocumentFlowCoordinator is @State on MainContentView, the WindowGroup content root, so the slot — like pendingAction, migrationError and the persisted-session binding — is per-scene. The code is unaffected; the rationale invites a future reader to think cross-window aliasing was the hazard. | Scoped both sentences to this window, and named the store that genuinely is app-wide (SessionFileManager's directory, keyed by session UUID) since that is what makes the unconditional content-file delete correct. |
| minor | DocumentFlowCoordinator.swift — handleSaveFailed doc (raised in round one, left standing) | "clearing the bookmark or pending action would take the replacement's" is offered as the justification for the session guard, but the code releases the bookmark above that guard whenever it belongs to this attempt. Ownership protects the bookmark; the guard protects the pending action and the alert. As written a reader concludes the bookmark is untouched on the late-failure path. | Split the sentence along the two mechanisms. |
| minor | docs/agent-notes/notes-system.md:102 — stale signature (raised in round one, left standing) | Still names DocumentFlowCoordinator.handleSaveFailed(notesRemainAt:), the signature this branch changed. The agent note is a live pointer, read before touching the subsystem. | Updated to handleSaveFailed(_:session:notesRemainAt:). The specs/clipboard-notes/decision_log.md reference is left alone as a historical record. |
| minor | The load-bearing fact nobody wrote down | Round one established that a late migration cannot touch the on-screen document's notes because NotesManager is @State on DocumentReaderView — one instance per reader, so the replacement gets a fresh one and the late migrateNotes rebinds an orphaned object. That is the whole reason the migration half needs no identity guard, and nothing in the branch, the report, or the agent note says it. Two review rounds have now had to re-derive it. | Recorded in the T-2213 section of notes-system.md. |
| minor | NotesManager.swift:1156-1158 — the id collision reaches NotesStore, unrecorded | migrateNotes ends with store.delete(for: sourceIdentifier) where the source is clipboard/{sessionID}. A restored session sharing that id therefore loses its notes file to another session's migration — the same collision the StatePersistence residual records, on a path with no ownership check of any kind and no mention anywhere. | Recorded alongside the NotesManager ownership fact in notes-system.md and in the StatePersistence residual, with the same bound (restorePersistedSession has one production caller, at window creation). |
| nit | report.md — Affected Files omits CHANGELOG.md (raised in round one, left standing) | The branch changes CHANGELOG.md and the table does not list it. The run-command mismatch from the same finding was fixed. | Added the row. Also added the residual entry for the id collision. |
| minor | DocumentFlowCoordinator.swift:80-82 — the third === is not pinned | PendingSaveBookmark.belongs(to:attempt:) spells the same discriminator as the two guards, and no test separates it from an id comparison: lateCompletionDoesNotConsumeTheReplacementsBookmark gives the replacement a fresh UUID, so an id-keyed belongs rejects it just as the reference-keyed one does. That test does pin the attempt half exactly — both attempts are PendingSave(id: 1, url: destination), so dropping the session check makes belongs match and the test fails — but the reference half is unheld. Separating it needs the collision fixture plus a second handleFileSave. | Not fixed. Unreachable in production by the same one-caller argument that bounds the recorded residual — but that argument is exactly what the two collision tests exist to distrust, so the standard is applied to two of three sites. |
| minor | Commit 2778dc31 — "Every guard mutation-tested individually" | The mutation table has four rows covering three guards and one branch. It has no row for PendingSaveBookmark.belongs(to:attempt:) — the T-2213 ownership check the fix introduces — nor for the switch from currentSession?.cachedDocumentTitle to session.cachedDocumentTitle. Both are covered by a test; neither is named in the table the claim points at. | Not fixed. Reproduced both surviving mutations myself: exit 65, total 71 passed 69 failed 2 for the id comparison, and total 71 passed 67 failed 4 for the dropped ownership check, matching the table where it does speak. |
| minor | LateSaveFinalisationTests.swift:348-358 — the fixture's collision cannot occur in production | The two collision tests build their state through restorePersistedSession(), which has one production caller — prismApp.swift:456, at window creation, before any save can be in flight. So the tests defend the discriminator against a state the branch's own residual argument says is unreachable. That is the right trade (the alternative is no pinning), but the MARK block argues only the mechanism, so a future reader cannot tell whether the tests describe a live hazard or a deliberately defensive one. | Not fixed — one sentence in the MARK block would do it. |
| nit | StatePersistenceTests.swift — clearOwnedByHandlesAnEmptySlot's storage assertion | #expect(storageData.isEmpty) checks a variable initialised to Data() and never written. The test's real content is the content-file deletion, which it does pin. Same vacuity class as the assertion this round repaired, one severity down. | Fixed after this review by 8fbd585e, correctly: the assertion is removed and replaced with a comment naming the two tests that DO pin the binding half, so it is not re-added by someone reading its absence as an oversight. The content-file assertion is kept. Re-verified: 71/71 on the rebased tree. |
| nit | "argued once, at the guard" is argued twice | notes-system.md says the reference-vs-id choice is "argued once, at the guard in completeSaveFlow" and adds "Do not re-argue it here". LateSaveFinalisationTests.swift:349-358 then re-argues it in full — init(persisted:), the in-place clipboard->file transition, the lot. Round one counted four copies and the fix commit reduced them to two, not one. | Not fixed. The test file's copy is the more useful of the two; the claim of singularity is what is wrong. |
| minor | Proportionality — comment-to-code ratio and reviewer-directed prose | clear(_:ownedBy:) is eight lines of code under roughly forty of comment, and several of those lines address a reviewer rather than a maintainer: "What keeps that narrow is NOT the coordinator's reference guard", "Recorded because it is a real residual limit ... rather than something left undone", and in the agent note "Do not re-argue it here". Permanent files carrying the argument of a review round is how the three drifting copies round one found came about. | Not fixed beyond correcting the two paragraphs that were wrong. What I would trim: the whole "What keeps that narrow is NOT" paragraph down to its last sentence (the one-caller bound), the "Recorded because" paragraph entirely, and the report's "Two rows worth reading twice" gloss on its own mutation table. |
| minor | Round one minors consciously left standing | The duplicated PersistedSessionMetadata decode in StatePersistence; the duplicated clearPersistedState(ownedBy:) either side of handleSaveFailed's guard, placed below it where completeSaveFlow places its equivalent above; activateSession not evicting a stale bookmark; handleFileSave's new guard let session = currentSession else { return } swallowing a successful export with no log; the fixture retain cycle in LateSaveFinalisationTests; and ReplacementHookStore being the fourth near-identical notes-store double in prismTests. | Not fixed. All are structural or hygiene items with no correctness consequence on this branch; the fixture dedup (setOnSave/setFailingPath on MockNotesStore) is the one worth doing before a fifth double appears. |
| minor | ConsecutiveSaveAsTests flake — assessment only, as asked | The reasoning holds where it goes and stops one step early. Correct: it is T-1812 code this PR does not touch, loadGeneration is a private instance var, isolation was 3/3, the full set is 71/71 twice. But "nothing shared could carry it between suites" is false. NotesManager.init unconditionally calls observeiCloudChanges(), registering a process-wide NotificationCenter observer for .NSUbiquityIdentityDidChange whose handler runs checkiCloudAvailability() -> ubiquityIdentityToken != nil, which is nil on a simulator. That sets iCloudAvailable = false, and loadNotes then hits guard iCloudAvailable else { clearNoteState(); return } — clearNoteState() sets documentNotes = nil, which is exactly what failed at line 324. makeForTesting sets the flag directly, bypassing applyiCloudAvailability, so nothing re-arms it. That is process-wide, timing-dependent, invisible in isolation, and consistent with "under load". | Warrants its own ticket, and the ticket should carry this hypothesis rather than "the reload in that test is defective" — the fix would be to make the test's manager immune to the notification, not to change the reload. Distinct from T-2236, which tracks live-WebPage flakiness on this simulator; nothing here involves WebKit. |
| minor | Branch history — conflict markers committed in CHANGELOG.md (introduced after this review) | The rebase onto origin/main resolved a CHANGELOG.md conflict by committing the markers. `git show 46c74062:CHANGELOG.md` and `git show 18fa987c:CHANGELOG.md` both contain <<<<<<< HEAD / ||||||| / >>>>>>> blocks around the T-2213 entry; 18fa987c carries two nested sets. Only CHANGELOG.md is affected — no Swift file — so nothing fails to compile, and the tip commit 8fbd585e removes them, leaving the final tree correct with the corrected wording intact. The consequence is confined to per-commit views: bisect, a non-squash merge, or any per-commit CI would surface a CHANGELOG with markers in it. | Not fixed. Prism squash-merges (every recent main commit is a squashed 'Fix T-xxxx (#NNN)'), so the markers never reach main, and rewriting history while another process was actively rebasing this same branch risks losing work. If a clean history is wanted, an interactive rebase fixing CHANGELOG.md in those two commits is the whole job — but do it when nothing else is operating on the branch. |
Click to expand.
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex 123af437..ca5ac3a9 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -59,11 +59,46 @@ final class DocumentFlowCoordinator { /// security-scoped access expires. Used by `completeSaveFlow` to register /// the saved file in recent files. ///- /// The URL it was created for travels with it: consecutive Save As- /// operations reuse this single slot, so a finalisation must prove the+ /// The attempt it was created for travels with it: this single slot is+ /// reused by every Save As in this window, 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)?+ /// entry labelled with one destination can resolve to another (T-1812), or a+ /// late finalisation from a replaced document can consume the bookmark the+ /// document now on screen is still waiting for (T-2213).+ private struct PendingSaveBookmark {+ /// The session that asked for the save. Weak, because a replaced+ /// document must not be kept alive by an unclaimed bookmark; a nil+ /// reference simply fails the ownership check.+ weak var session: DocumentSession?++ /// The attempt within that session. `PendingSave.id` is monotonic per+ /// session, so session + attempt names one Save As in this window's+ /// life. `DocumentFlowCoordinator` is `@State` on `MainContentView`,+ /// the `WindowGroup` content root, so this slot — like `pendingAction`,+ /// `migrationError` and the persisted-session binding — is per-scene.+ /// The genuinely app-wide store in play is `SessionFileManager`'s+ /// directory, keyed by session UUID, which is why the content-file+ /// delete in `clear(_:ownedBy:)` needs no window scoping.+ let attempt: DocumentSession.PendingSave++ let data: Data++ func belongs(to session: DocumentSession, attempt: DocumentSession.PendingSave) -> Bool {+ self.session === session && self.attempt == attempt+ }+ }++ private var pendingSaveBookmark: PendingSaveBookmark?++ #if DEBUG+ /// Test-only view of whether the eager bookmark slot is still occupied.+ ///+ /// A bookmark wrongly consumed by a foreign finalisation has no other+ /// observable signature — the recents entry it produces resolves either way+ /// in a unit-test host — so the T-2213 ownership guard is pinned through+ /// this. Stripped from release builds.+ var hasPendingSaveBookmarkForTests: Bool { pendingSaveBookmark != nil }+ #endif // MARK: - Injected Dependencies @@ -399,12 +434,15 @@ final class DocumentFlowCoordinator { func handleFileSave(_ result: Result<URL, Error>) { switch result { case .success(let url):+ guard let session = currentSession else { return }+ let attempt = session.prepareSave(to: url) // Create bookmark data immediately while we still have security-scoped // 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.- pendingSaveBookmark = Self.createBookmarkData(for: url).map { (url, $0) }- currentSession?.prepareSave(to: url)+ pendingSaveBookmark = Self.createBookmarkData(for: url).map {+ PendingSaveBookmark(session: session, attempt: attempt, data: $0)+ } case .failure(let error): pendingAction = nil@@ -416,23 +454,63 @@ final class DocumentFlowCoordinator { } /// Finalizes save flow after clipboard note migration succeeds.- func completeSaveFlow(_ url: URL) {- clearPersistedState()+ ///+ /// Every effect here is scoped to the document that asked for the save+ /// rather than to whatever is on screen when the notes work finishes. The+ /// two can differ: `ClipboardSaveFlow` runs to completion even after its+ /// reader disappears (its `cancel()` is advisory by design — an abandoned+ /// migration would leave the notes half-moved), so a save started for+ /// document A can finalise while document B is installed. Reading+ /// `currentSession` at that point attributes A's save to B — B's title on+ /// A's recents entry, B's restore point erased, B's pending action fired+ /// (T-2213).+ ///+ /// The discriminator is the session *reference*, not its id — the single+ /// statement of that choice; everything else that mentions it points here.+ /// The two agree almost everywhere, including across the clipboard→file+ /// transition this method finalises, which mutates the session in place and+ /// so moves neither. What separates them is `DocumentSession.init(persisted:)`,+ /// which re-uses a stored id verbatim: a document restored from a saved+ /// scene can carry the same id as a session that is still live, so an id+ /// comparison would call it the same document. Two distinct objects never+ /// compare `===`. Pinned by `LateSaveFinalisationTests`' two+ /// "reused the saving session's id" tests, which build exactly that+ /// collision and fail under an id comparison.+ ///+ /// - Parameters:+ /// - attempt: The save attempt being finalised. Carries the destination.+ /// - session: The session that started it.+ func completeSaveFlow(_ attempt: DocumentSession.PendingSave, session: DocumentSession) {+ let url = attempt.url - if let bookmark = pendingSaveBookmark, bookmark.url == url {+ // The saving session's own state, whether or not it is still installed:+ // it has just become a file document, so its clipboard restore point+ // must go — but only its own (see `clear(_:ownedBy:)`).+ clearPersistedState(ownedBy: session)++ if let bookmark = pendingSaveBookmark, bookmark.belongs(to: session, attempt: attempt) { pendingSaveBookmark = nil documentServices?.recentFilesManager.addSavedFile( url: url, bookmarkData: bookmark.data,- title: currentSession?.cachedDocumentTitle+ title: session.cachedDocumentTitle ) } else { // Fallback: try direct bookmark creation (may work in some environments). // This path means the eager bookmark in handleFileSave failed, or the- // stored one belongs to a different destination — log it so the+ // stored one belongs to a different attempt or document — 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)+ documentServices?.recentFilesManager.addSavedFile(url: url, title: session.cachedDocumentTitle)+ }++ // The pending action is the one the user deferred behind *this* save+ // ("Save, then open X"). Once the document has been replaced, whatever+ // sits in the slot belongs to the replacement's own flow, and running it+ // here would fire it early and out of context.+ guard currentSession === session else {+ logger.notice("completeSaveFlow: \(url.lastPathComponent, privacy: .public) finalised after its document was replaced; recents entry added, session-scoped effects skipped")+ return } if let action = pendingAction {@@ -450,11 +528,42 @@ final class DocumentFlowCoordinator { /// 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+ /// Like `completeSaveFlow`, this is reached after an `await` and so can land+ /// on a document that is no longer installed. It is scoped the same way and+ /// for the same reason (T-2213), with one extra consequence: the alert. A+ /// failure alert names the document the user was saving, so raising it over+ /// a replacement document reports a file they are not looking at and did not+ /// just save.+ ///+ /// The guard protects the alert and the pending action, and nothing else.+ /// The bookmark is released *above* it, protected by ownership rather than+ /// by the guard: this attempt's bookmark is its own to release whether or+ /// not the document is still installed, and a newer attempt's is never+ /// touched because `belongs(to:attempt:)` declines it.+ ///+ /// - Parameters:+ /// - attempt: The save attempt that failed.+ /// - session: The session that started it.+ /// - 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(_ attempt: DocumentSession.PendingSave,+ session: DocumentSession,+ notesRemainAt: URL? = nil) {+ if let bookmark = pendingSaveBookmark, bookmark.belongs(to: session, attempt: attempt) {+ pendingSaveBookmark = nil+ }++ guard currentSession === session else {+ logger.notice("handleSaveFailed: \(attempt.url.lastPathComponent, privacy: .public) failed after its document was replaced; not reporting over the current document")+ // The settle still happened on the saving session, so its clipboard+ // restore point is as stale as on the success path.+ if notesRemainAt != nil {+ clearPersistedState(ownedBy: session)+ }+ return+ }+ pendingAction = nil if let notesRemainAt { // The session is now a file document, so `toPersistableState()`@@ -465,7 +574,7 @@ final class DocumentFlowCoordinator { // 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()+ clearPersistedState(ownedBy: session) 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."@@ -722,6 +831,16 @@ final class DocumentFlowCoordinator { StatePersistence.clear(binding, sessionID: currentSession?.id) } + /// Clears persisted state for one named session, leaving any other+ /// session's restore point intact.+ ///+ /// Used by the save-finalisation paths, which are the only ones that can+ /// run for a session other than the installed one (T-2213).+ private func clearPersistedState(ownedBy session: DocumentSession) {+ guard let binding = documentServices?.persistedSessionData else { return }+ StatePersistence.clear(binding, ownedBy: session.id)+ }+ /// Creates security-scoped bookmark data for a URL. /// /// Uses platform-appropriate options: macOS requires `withSecurityScope` and
diff --git a/prism/Services/StatePersistence.swift b/prism/Services/StatePersistence.swiftindex 1916b290..0989f36b 100644--- a/prism/Services/StatePersistence.swift+++ b/prism/Services/StatePersistence.swift@@ -105,4 +105,66 @@ enum StatePersistence { SessionFileManager.deleteContent(for: id) } }++ /// Clears the persisted state belonging to one specific session, leaving+ /// another session's restore point intact.+ ///+ /// `clear(_:sessionID:)` wipes the storage binding unconditionally, which is+ /// right for every caller that acts on the session currently installed. The+ /// asynchronous half of a clipboard Save As is not such a caller: it can+ /// finalise after its document has been replaced, and an unconditional wipe+ /// there erases the *replacement* document's restore point and deletes its+ /// content file — the replacement's pasted text is then unrecoverable on the+ /// next launch (T-2213).+ ///+ /// So this variant asks who the slot belongs to before wiping it — but only+ /// for the half that can belong to someone else. The shared binding is wiped+ /// only when its metadata names this session; anything else — another+ /// session's metadata, an empty slot, undecodable data — is not this+ /// session's to clear. The content file is keyed by session id and is+ /// removed unconditionally, because the whole point of the call is that this+ /// session is no longer a clipboard document and no *other* id names that+ /// file. Which is the safe reading only while ids are unique — see the+ /// residual below, where it is the unchecked half that costs the most.+ ///+ /// The ownership test is by id, not by object identity, and it has to be:+ /// what is on disk is a `UUID`, and disk storage has no reference to compare.+ /// That leaves one case this cannot separate — a session restored from+ /// persisted state reuses its stored id verbatim (`DocumentSession.init(persisted:)`),+ /// so a restored session sharing an id with a still-live one is+ /// indistinguishable here. Both halves are affected, and the unchecked one+ /// is the worse: the binding match would name the wrong session, while the+ /// content-file delete never asks at all — it would take the colliding+ /// session's pasted text, which is the loss T-2213 exists to stop, wearing+ /// the fix for it. The same collision reaches `NotesStore` independently and+ /// unguarded, through the `clipboard/{id}` identifier `migrateNotes` deletes+ /// once it has moved the notes.+ ///+ /// What keeps that narrow is NOT the coordinator's reference guard. Both+ /// finalisation paths reach this deliberately without it: `completeSaveFlow`+ /// calls it unconditionally, above its `currentSession === session` guard,+ /// and `handleSaveFailed` calls it inside the branch taken when that guard+ /// FAILS — because the saving session's clipboard restore point is stale+ /// whether or not that session is still installed, which is the entire point+ /// of an ownership-keyed clear. What actually bounds the collision is that a+ /// stored id only re-enters the process through `restorePersistedSession`,+ /// which has one production caller, at window creation.+ ///+ /// Recorded because it is a real residual limit of id-keyed persistence+ /// rather than something left undone — closing it would take an identity on+ /// disk that outlives the process (T-2213 review).+ ///+ /// - Parameters:+ /// - storage: A binding to SceneStorage data.+ /// - sessionID: The session whose persisted state should be cleared.+ static func clear(_ storage: Binding<Data>, ownedBy sessionID: UUID) {+ SessionFileManager.deleteContent(for: sessionID)++ guard let metadata = try? JSONDecoder().decode(+ PersistedSessionMetadata.self,+ from: storage.wrappedValue+ ), metadata.sessionID == sessionID else { return }++ storage.wrappedValue = Data()+ } }
diff --git a/prism/ViewModels/ClipboardSaveFlow.swift b/prism/ViewModels/ClipboardSaveFlow.swiftindex 5271fd50..492549d9 100644--- a/prism/ViewModels/ClipboardSaveFlow.swift+++ b/prism/ViewModels/ClipboardSaveFlow.swift@@ -35,6 +35,16 @@ private let logger = Logger.prism(category: "ClipboardSaveFlow") /// /// Finalising covers failure too, and there it has to name the file the notes /// are actually on rather than assuming the clipboard — see `finaliseFailure`.+///+/// Rule 2 only protects the *session* — `isCurrentSaveAttempt` answers "has this+/// document moved past me", never "is this document still open". This flow+/// outlives its reader on purpose (`cancel()` is advisory), so a finalisation+/// can land while a different document is installed, and the coordinator's side+/// of it — recents, persisted state, the pending action — reads app-wide state.+/// Both callbacks therefore carry the session and attempt they belong to, so+/// `DocumentFlowCoordinator` can scope its effects to the document that asked+/// for the save instead of the one on screen when the notes work finished+/// (T-2213). @MainActor final class ClipboardSaveFlow { /// The in-flight attempt's task, awaited by the next attempt.@@ -70,15 +80,19 @@ final class ClipboardSaveFlow { /// 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.+ /// - Parameters:+ /// - onCompleted: Called when the save finalises, carrying the session and+ /// attempt it belongs to — which may no longer be the installed+ /// document (T-2213).+ /// - onFailed: Called when the notes could not be moved, carrying the same+ /// identity plus the file the notes 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+ onCompleted: @escaping (DocumentSession, DocumentSession.PendingSave) -> Void,+ onFailed: @escaping (DocumentSession, DocumentSession.PendingSave, URL?) -> Void ) { guard let attempt = session.pendingSave else { return } @@ -119,8 +133,8 @@ final class ClipboardSaveFlow { attempt: DocumentSession.PendingSave, session: DocumentSession, notesManager: NotesManager,- onCompleted: @escaping (URL) -> Void,- onFailed: @escaping (URL?) -> Void+ onCompleted: @escaping (DocumentSession, DocumentSession.PendingSave) -> Void,+ onFailed: @escaping (DocumentSession, DocumentSession.PendingSave, URL?) -> Void ) async { let migrationSource = previousDestination @@ -151,6 +165,7 @@ final class ClipboardSaveFlow { previousDestination = migrationSource guard session.isCurrentSaveAttempt(attempt) else { return } finaliseFailure(+ attempt: attempt, session: session, notesRemainAt: migrationSource, onFailed: onFailed@@ -175,7 +190,7 @@ final class ClipboardSaveFlow { previousDestination = nil session.didSave(to: attempt.url)- onCompleted(attempt.url)+ onCompleted(session, attempt) } /// Settles the session onto whichever document actually holds the notes@@ -198,9 +213,10 @@ final class ClipboardSaveFlow { /// branches — the notes are once again under an identity the session itself /// describes, so the chain is over. private func finaliseFailure(+ attempt: DocumentSession.PendingSave, session: DocumentSession, notesRemainAt: URL?,- onFailed: @escaping (URL?) -> Void+ onFailed: @escaping (DocumentSession, DocumentSession.PendingSave, URL?) -> Void ) { previousDestination = nil if let notesRemainAt {@@ -210,6 +226,6 @@ final class ClipboardSaveFlow { logger.error("Clipboard note migration failed for session \(session.id, privacy: .public), reverting to clipboard") session.revertToClipboard() }- onFailed(notesRemainAt)+ onFailed(session, attempt, notesRemainAt) } }
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex 665cb37a..28eeef29 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -54,13 +54,18 @@ struct DocumentReaderView: View { /// Called when save button is tapped (for clipboard content). let onSave: () -> Void - /// Called when a clipboard save fully completes (including note migration).- let onSaveCompleted: (URL) -> Void+ /// Called when a clipboard save fully completes (including note migration),+ /// carrying the session and attempt it belongs to.+ ///+ /// The identity travels with the callback because the notes work outlives+ /// this view: by the time it finishes, the document it saved may no longer+ /// be the installed one (T-2213).+ let onSaveCompleted: (DocumentSession, DocumentSession.PendingSave) -> 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+ /// session and attempt it belongs to plus the file the notes remain on+ /// (`nil` when they remain under the clipboard identifier).+ let onSaveFailed: (DocumentSession, DocumentSession.PendingSave, URL?) -> Void /// Called when a link tap resolves to an in-app navigation target. var onOpenLink: ((ResolvedLink) -> Void)?@@ -681,8 +686,8 @@ extension EnvironmentValues { session: session, onClose: {}, onSave: {},- onSaveCompleted: { _ in },- onSaveFailed: { _ in }+ onSaveCompleted: { _, _ in },+ onSaveFailed: { _, _, _ in } ) } .environment(AppSettings())@@ -701,8 +706,8 @@ extension EnvironmentValues { session: session, onClose: {}, onSave: {},- onSaveCompleted: { _ in },- onSaveFailed: { _ in }+ onSaveCompleted: { _, _ in },+ onSaveFailed: { _, _, _ in } ) } .environment(AppSettings())@@ -718,8 +723,8 @@ extension EnvironmentValues { session: session, onClose: {}, onSave: {},- onSaveCompleted: { _ in },- onSaveFailed: { _ in }+ onSaveCompleted: { _, _ in },+ onSaveFailed: { _, _, _ in } ) } .environment(AppSettings())
diff --git a/prism/prismApp.swift b/prism/prismApp.swiftindex 9f53b258..2963b9dd 100644--- a/prism/prismApp.swift+++ b/prism/prismApp.swift@@ -307,9 +307,15 @@ struct MainContentView: View { session: session, onClose: { flowCoordinator.requestClose() }, onSave: { flowCoordinator.isFileExporterPresented = true },- onSaveCompleted: { url in flowCoordinator.completeSaveFlow(url) },- onSaveFailed: { notesRemainAt in- flowCoordinator.handleSaveFailed(notesRemainAt: notesRemainAt)+ onSaveCompleted: { savingSession, attempt in+ flowCoordinator.completeSaveFlow(attempt, session: savingSession)+ },+ onSaveFailed: { savingSession, attempt, notesRemainAt in+ flowCoordinator.handleSaveFailed(+ attempt,+ session: savingSession,+ notesRemainAt: notesRemainAt+ ) }, onOpenLink: { resolved in handleResolvedLink(resolved)
diff --git a/prismTests/LateSaveFinalisationTests.swift b/prismTests/LateSaveFinalisationTests.swiftnew file mode 100644index 00000000..7b81560c--- /dev/null+++ b/prismTests/LateSaveFinalisationTests.swift@@ -0,0 +1,443 @@+//+// LateSaveFinalisationTests.swift+// prismTests+//+// T-2213: A clipboard Save As can finalise after its document has been+// replaced.+//+// `ClipboardSaveFlow` deliberately outlives its reader — its `cancel()` is+// advisory because an abandoned migration would leave the notes half-moved —+// so the notes work can still be parked in the store when the user closes the+// document or opens another one. `DocumentSession.isCurrentSaveAttempt` guards+// the *session's* own state, but it answers "has this document moved past me",+// never "is this document still open": the coordinator's half of the+// finalisation read `currentSession`, the shared bookmark slot, the shared+// persisted-state binding, and the shared pending action, all of which by then+// belong to the replacement document.+//+// Expected behaviour: a finalisation applies to the document that asked for+// the save. Its recents entry is its own, its restore point is the one that is+// cleared, and nothing that belongs to the document now on screen is touched.+//+// Each test drives the ordering rather than asserting an end state: the+// replacement is installed from inside `NotesStore.save`, i.e. while the+// migration is suspended, so the late completion is deterministic.+//++import Foundation+import SwiftUI+import Testing+@testable import prism++/// A store that runs a hook inside `save` — so a test can install a+/// replacement document while a migration is parked — and can fail the write+/// for one destination.+private actor ReplacementHookStore: NotesStoreProtocol {+ var storedNotes: [String: DocumentNotes] = [:]+ var isAvailable: Bool { true }+ private var onSave: (@Sendable () async -> Void)?+ private var failingPath: String?++ func setOnSave(_ hook: @escaping @Sendable () async -> Void) {+ onSave = hook+ }++ func setFailingPath(_ path: String?) {+ failingPath = path+ }++ func load(for identifier: DocumentIdentifier) async -> DocumentNotes? {+ storedNotes[identifier.path]+ }++ func save(_ notes: DocumentNotes) async throws {+ if let hook = onSave {+ onSave = nil+ await hook()+ }+ 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("Late Save Finalisation", .serialized)+@MainActor+struct LateSaveFinalisationTests {++ // MARK: - Fixture++ /// Everything one scenario needs: a coordinator wired to an isolated+ /// recents store and a live persisted-state binding, plus a temp directory+ /// to save into.+ @MainActor+ private final class Fixture {+ let coordinator = DocumentFlowCoordinator()+ let recentFiles: RecentFilesManager+ let directory: URL+ let suiteName: String+ private let defaults: UserDefaults+ /// Backing storage for the persisted-session binding. A live box, not+ /// `.constant`: the whole question is who is allowed to clear it.+ private var persisted = Data()+ /// Session ids whose content files this fixture created.+ private var trackedSessionIDs: [UUID] = []++ /// The scene-storage binding the coordinator writes through.+ private var binding: Binding<Data> {+ Binding(get: { [self] in persisted }, set: { [self] in persisted = $0 })+ }++ init() throws {+ suiteName = UUID().uuidString+ defaults = try #require(UserDefaults(suiteName: suiteName))+ defaults.removePersistentDomain(forName: suiteName)+ recentFiles = RecentFilesManager(storage: defaults)+ directory = FileManager.default.temporaryDirectory+ .appendingPathComponent("t2213-\(UUID().uuidString)", isDirectory: true)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)++ coordinator.setDocumentServicesForTests(DocumentServices(+ recentFilesManager: recentFiles,+ persistedSessionData: binding,+ bundledDocumentState: BundledDocumentState(defaults: defaults)+ ))+ }++ /// A file that exists on disk, so the eager bookmark in+ /// `handleFileSave` has something real to point at.+ func destination(_ name: String) throws -> URL {+ let url = directory.appendingPathComponent(name)+ try Data("# Saved".utf8).write(to: url)+ return url+ }++ /// Gives a session the restore point a background transition writes for+ /// it while it is still pasted.+ ///+ /// Without this a saving session has no content file at all, so+ /// "its restore point was removed" is true of something that never+ /// existed and holds just as well with the fix reverted.+ func persistRestorePoint(for session: DocumentSession) throws {+ let state = try #require(session.toPersistableState(),+ "only a clipboard session has a restore point")+ StatePersistence.save(state, to: binding)+ }++ /// Installs a replacement clipboard document the way the app does:+ /// through `restorePersistedSession`, which is the one public path that+ /// produces a clipboard session *with* a restore point.+ ///+ /// - Parameter sessionID: The id the restore point carries, which+ /// `DocumentSession.init(persisted:)` re-uses verbatim. Pass a live+ /// session's id to build the one case where two distinct sessions+ /// share an id.+ @discardableResult+ func installReplacementDocument(sessionID: UUID = UUID(), content: String) -> DocumentSession? {+ trackedSessionIDs.append(sessionID)+ StatePersistence.save(+ PersistedSessionState(sessionID: sessionID, content: content, scrollPositionID: ""),+ to: binding+ )+ coordinator.restorePersistedSession()+ return coordinator.currentSession+ }++ /// The restore point currently in the scene binding, if any.+ var restorePoint: PersistedSessionState? {+ StatePersistence.load(from: persisted)+ }++ /// Runs one Save As attempt: the exporter callback, then the notes work.+ func startSave(_ session: DocumentSession,+ to url: URL,+ manager: NotesManager,+ on flow: ClipboardSaveFlow) {+ coordinator.handleFileSave(.success(url))+ flow.start(+ session: session,+ notesManager: manager,+ onCompleted: { [coordinator] savingSession, attempt in+ coordinator.completeSaveFlow(attempt, session: savingSession)+ },+ onFailed: { [coordinator] savingSession, attempt, notesRemainAt in+ coordinator.handleSaveFailed(attempt, session: savingSession, notesRemainAt: notesRemainAt)+ }+ )+ }++ func track(_ session: DocumentSession) {+ trackedSessionIDs.append(session.id)+ }++ func tearDown() {+ for id in trackedSessionIDs {+ SessionFileManager.deleteContent(for: id)+ }+ defaults.removePersistentDomain(forName: suiteName)+ try? FileManager.default.removeItem(at: directory)+ }+ }++ /// A pasted document carrying one note, i.e. the state a Save As starts+ /// from, installed as the coordinator's current document.+ private func makeSavingDocument(+ in fixture: Fixture,+ store: ReplacementHookStore,+ content: String = "# Alpha\n\nPasted"+ ) async -> (session: DocumentSession, manager: NotesManager) {+ let session = DocumentSession(clipboardContent: content)+ await session.parseContent()+ let manager = NotesManager.makeForTesting(store: store)+ if let block = session.parsedBlocks.last {+ await manager.createNote(+ content: "Clipboard note",+ for: block,+ sourceIndex: session.parsedBlocks.count - 1,+ in: session.documentStructure,+ source: .clipboard,+ sessionID: session.id+ )+ }+ fixture.track(session)+ fixture.coordinator.currentSession = session+ return (session, manager)+ }++ /// Runs one Save As to completion, installing whatever `whileMigrating`+ /// does at the moment the migration is parked in the notes store.+ private func save(+ _ session: DocumentSession,+ to url: URL,+ manager: NotesManager,+ store: ReplacementHookStore,+ in fixture: Fixture,+ whileMigrating: @escaping @Sendable @MainActor () -> Void+ ) async {+ await store.setOnSave {+ await MainActor.run { whileMigrating() }+ }++ let saveFlow = ClipboardSaveFlow()+ // The exporter callback writes the eager bookmark and starts the+ // attempt, exactly as production does.+ fixture.startSave(session, to: url, manager: manager, on: saveFlow)+ await saveFlow.drain()+ }++ // MARK: - Regression++ @Test("a late completion leaves the replacement document's restore point alone")+ func lateCompletionKeepsTheReplacementRestorePoint() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ // The saving document was backgrounded while it was still pasted, so it+ // has a restore point of its own to lose.+ try fixture.persistRestorePoint(for: session)+ #expect(SessionFileManager.readContent(for: session.id) != nil)+ let destination = try fixture.destination("alpha.md")++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(content: "# Replacement")+ }++ let replacementID = try #require(fixture.coordinator.currentSession?.id)+ // Bug: the finalisation cleared "the persisted state" by reading+ // `currentSession`, which wiped the scene binding and deleted the+ // replacement's content file — its pasted text was gone for good.+ let restorePoint = try #require(fixture.restorePoint)+ #expect(restorePoint.sessionID == replacementID)+ #expect(restorePoint.content == "# Replacement")+ // The saving document's own restore point is still the one that goes:+ // it is a file document now. Meaningful only because it had one.+ #expect(SessionFileManager.readContent(for: session.id) == nil)+ }++ @Test("a late completion labels its recents entry with its own document's title")+ func lateCompletionUsesTheSavingDocumentsTitle() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ let destination = try fixture.destination("alpha.md")++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(content: "# Replacement")+ }++ // Bug: the title came from `currentSession`, so the entry for the file+ // that was actually saved was labelled with the replacement document.+ let entry = try #require(fixture.recentFiles.recentFiles.first { $0.fileName == "alpha.md" })+ #expect(entry.title == "Alpha")+ }++ @Test("a late completion does not run the replacement document's pending action")+ func lateCompletionDoesNotRunTheReplacementsPendingAction() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ let destination = try fixture.destination("alpha.md")++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(content: "# Replacement")+ // The replacement has its own deferred action awaiting its own+ // confirmation dialog.+ fixture.coordinator.pendingAction = .close+ }++ // Bug: the late completion executed it, closing a document the user had+ // just opened and never confirmed closing.+ #expect(fixture.coordinator.currentSession != nil)+ #expect(fixture.coordinator.pendingAction != nil)+ }++ @Test("a late completion does not consume the replacement document's bookmark")+ func lateCompletionDoesNotConsumeTheReplacementsBookmark() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ let destination = try fixture.destination("alpha.md")++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(content: "# Replacement")+ // The replacement is saved to the same name, so the T-1812 guard —+ // which compares URLs — cannot tell the two bookmarks apart.+ fixture.coordinator.handleFileSave(.success(destination))+ }++ // Bug: the URL matched, so the late completion took the bookmark the+ // replacement's own finalisation still needs, leaving that one to fall+ // back to a bookmark it can no longer create with security scope.+ #expect(fixture.coordinator.hasPendingSaveBookmarkForTests,+ "the replacement document's eager bookmark was consumed by another document's finalisation")+ // The saved file is still registered — the file was written, and this+ // document is the one that wrote it.+ #expect(fixture.recentFiles.recentFiles.contains { $0.fileName == "alpha.md" })+ }++ @Test("a late migration failure does not report over the replacement document")+ func lateFailureDoesNotReportOverTheReplacement() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ let destination = try fixture.destination("alpha.md")+ await store.setFailingPath(DocumentIdentifierResolver().resolve(from: destination).path)++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(content: "# Replacement")+ fixture.coordinator.pendingAction = .close+ }++ // Bug: the alert named a file the user is no longer looking at and did+ // not just save, and the replacement's deferred action was dropped.+ #expect(fixture.coordinator.migrationError == nil)+ #expect(fixture.coordinator.pendingAction != nil)+ #expect(fixture.restorePoint?.content == "# Replacement")+ }++ // MARK: - The Discriminator+ //+ // Every test above passes just as well with the guards comparing+ // `session.id`, because each replacement is a fresh session with a fresh+ // id. That is not an accident of the fixture — it is nearly always true:+ // the clipboard→file transition a save performs mutates the session in+ // place, so the saving session's id never moves either. The one case that+ // separates the two is `DocumentSession.init(persisted:)`, which re-uses a+ // stored id verbatim: a restored document can carry the same id as a+ // session that is still live. The two tests below build that case, and+ // fail if either guard is loosened from `===` to an id comparison.++ @Test("a late completion is not fooled by a document that reused the saving session's id")+ func lateCompletionSeparatesADocumentThatReusedTheSavingSessionsID() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ let destination = try fixture.destination("alpha.md")++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(sessionID: session.id, content: "# Replacement")+ // The replacement has its own deferred action awaiting its own+ // confirmation dialog.+ fixture.coordinator.pendingAction = .close+ }++ // Two distinct documents, one id: an id comparison calls the+ // replacement "the document that asked for the save" and runs its+ // deferred close without the user ever confirming it.+ let replacement = try #require(fixture.coordinator.currentSession,+ "the replacement's deferred close ran")+ #expect(replacement !== session)+ #expect(replacement.id == session.id)+ #expect(fixture.coordinator.pendingAction != nil)+ }++ @Test("a late failure is not fooled by a document that reused the saving session's id")+ func lateFailureSeparatesADocumentThatReusedTheSavingSessionsID() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ let destination = try fixture.destination("alpha.md")+ await store.setFailingPath(DocumentIdentifierResolver().resolve(from: destination).path)++ await save(session, to: destination, manager: manager, store: store, in: fixture) {+ fixture.installReplacementDocument(sessionID: session.id, content: "# Replacement")+ fixture.coordinator.pendingAction = .close+ }++ // Same collision on the failure side: an id comparison raises the+ // migration alert over the replacement and discards its pending action.+ let replacement = try #require(fixture.coordinator.currentSession)+ #expect(replacement !== session)+ #expect(replacement.id == session.id)+ #expect(fixture.coordinator.migrationError == nil)+ #expect(fixture.coordinator.pendingAction != nil)+ }++ // MARK: - Mid-Chain Failure++ @Test("a late mid-chain failure clears only the saving document's restore point")+ func lateMidChainFailureClearsOnlyTheSavingDocumentsRestorePoint() async throws {+ let fixture = try Fixture()+ defer { fixture.tearDown() }+ let store = ReplacementHookStore()+ let (session, manager) = await makeSavingDocument(in: fixture, store: store)+ try fixture.persistRestorePoint(for: session)+ let first = try fixture.destination("first.md")+ let second = try fixture.destination("second.md")++ // Only the second destination's write fails, so attempt 1 moves the+ // notes onto `first` and attempt 2 settles the session there. That+ // `notesRemainAt != nil` settle is the one effect `handleSaveFailed`+ // still performs when the saving document is gone: its clipboard+ // restore point is stale either way, because it is a file document now.+ await store.setFailingPath(DocumentIdentifierResolver().resolve(from: second).path)+ await store.setOnSave {+ await MainActor.run { _ = fixture.installReplacementDocument(content: "# Replacement") }+ }++ let saveFlow = ClipboardSaveFlow()+ // Both attempts are queued before either runs, so attempt 1 is+ // superseded and never finalises — which is what leaves the saving+ // session's restore point standing for attempt 2 to clear.+ fixture.startSave(session, to: first, manager: manager, on: saveFlow)+ fixture.startSave(session, to: second, manager: manager, on: saveFlow)+ await saveFlow.drain()++ #expect(session.source == .file(url: first))+ #expect(fixture.coordinator.migrationError == nil)+ #expect(SessionFileManager.readContent(for: session.id) == nil)+ #expect(fixture.restorePoint?.content == "# Replacement")+ }+}
diff --git a/prismTests/StatePersistenceTests.swift b/prismTests/StatePersistenceTests.swiftindex 59ccdea8..d0f9bd5f 100644--- a/prismTests/StatePersistenceTests.swift+++ b/prismTests/StatePersistenceTests.swift@@ -226,6 +226,101 @@ struct StatePersistenceTests { #expect(storageData.isEmpty) } + // MARK: - Clear (ownership-scoped, T-2213)+ //+ // The scene binding is a single slot shared by whichever session is+ // current, but the content file is per-session. `clear(_:ownedBy:)` is the+ // variant used by the save-finalisation paths, which can run for a session+ // other than the installed one: it always removes that session's own+ // content file, and wipes the shared binding only when the binding's+ // metadata names that session.++ @Test("clear(ownedBy:) removes its own content file and its own metadata")+ func clearOwnedByRemovesItsOwnSlot() {+ var storageData = Data()+ let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })++ let sessionID = UUID()+ StatePersistence.save(+ PersistedSessionState(sessionID: sessionID, content: "Owned", scrollPositionID: "block-1"),+ to: binding+ )++ StatePersistence.clear(binding, ownedBy: sessionID)++ #expect(storageData.isEmpty)+ #expect(SessionFileManager.readContent(for: sessionID) == nil)+ }++ @Test("clear(ownedBy:) leaves another session's metadata intact")+ func clearOwnedByLeavesAnotherSessionsSlotIntact() {+ var storageData = Data()+ let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })++ let owner = UUID()+ let other = UUID()+ StatePersistence.save(+ PersistedSessionState(sessionID: owner, content: "Owner", scrollPositionID: "block-1"),+ to: binding+ )+ // The second save re-points the shared binding at `other` while both+ // content files exist — the state a late finalisation lands in.+ StatePersistence.save(+ PersistedSessionState(sessionID: other, content: "Other", scrollPositionID: "block-2"),+ to: binding+ )++ StatePersistence.clear(binding, ownedBy: owner)++ // The owner's own file goes; the slot and file that belong to the+ // other session are not the owner's to clear.+ #expect(SessionFileManager.readContent(for: owner) == nil)+ let remaining = StatePersistence.load(from: storageData)+ #expect(remaining?.sessionID == other)+ #expect(remaining?.content == "Other")++ SessionFileManager.deleteContent(for: other)+ }++ @Test("clear(ownedBy:) still removes the content file when the slot is empty")+ func clearOwnedByHandlesAnEmptySlot() {+ var storageData = Data()+ let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })++ let sessionID = UUID()+ SessionFileManager.writeContent("Orphaned", for: sessionID)++ StatePersistence.clear(binding, ownedBy: sessionID)++ // Only the content-file assertion discriminates here. `storageData`+ // starts empty, so asserting it is STILL empty passes whether the+ // ownership guard runs or not — an empty slot fails to decode, the+ // guard returns early, and a guard-less version would assign+ // `Data()` over `Data()`. The binding half of this contract is+ // pinned by `clearOwnedByLeavesAnotherSessionsSlotIntact` and+ // `clearOwnedByLeavesUndecodableDataAlone`, which seed the slot with+ // something a wrongly-unconditional wipe would destroy. Do not+ // re-add an `isEmpty` check here thinking it covers that; it reads+ // like coverage and is not (T-2213 pre-push).+ #expect(SessionFileManager.readContent(for: sessionID) == nil)+ }++ @Test("clear(ownedBy:) leaves undecodable slot data alone")+ func clearOwnedByLeavesUndecodableDataAlone() {+ var storageData = Data([0x01, 0x02, 0x03])+ let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })++ let sessionID = UUID()+ SessionFileManager.writeContent("Owned", for: sessionID)++ StatePersistence.clear(binding, ownedBy: sessionID)++ // Data that names no session names no owner either, so there is+ // nothing here this session is entitled to wipe.+ #expect(storageData == Data([0x01, 0x02, 0x03]))+ #expect(SessionFileManager.readContent(for: sessionID) == nil)+ }+ // MARK: - Round Trip @Test("Round-trip preserves all fields")
diff --git a/prismTests/ConsecutiveSaveAsTests.swift b/prismTests/ConsecutiveSaveAsTests.swiftindex a6abe6c8..f060d500 100644--- a/prismTests/ConsecutiveSaveAsTests.swift+++ b/prismTests/ConsecutiveSaveAsTests.swift@@ -125,12 +125,14 @@ struct ConsecutiveSaveAsTests { let flow = ClipboardSaveFlow() session.prepareSave(to: firstURL)- flow.start(session: session, notesManager: manager, onCompleted: onCompleted, onFailed: onFailed)+ flow.start(session: session, notesManager: manager, onCompleted: { _, attempt in onCompleted(attempt.url) },+ onFailed: { _, _, notesRemainAt in onFailed(notesRemainAt) }) // 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)+ flow.start(session: session, notesManager: manager, onCompleted: { _, attempt in onCompleted(attempt.url) },+ onFailed: { _, _, notesRemainAt in onFailed(notesRemainAt) }) await flow.drain() }@@ -198,9 +200,9 @@ struct ConsecutiveSaveAsTests { // 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 })+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in }, onFailed: { _, _, _ in }) session.prepareSave(to: firstURL)- flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { _ in })+ 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@@ -228,7 +230,8 @@ struct ConsecutiveSaveAsTests { // 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) })+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in },+ onFailed: { _, _, notesRemainAt in failures.append(notesRemainAt) }) await flow.drain() #expect(session.source == .clipboard)@@ -259,7 +262,8 @@ struct ConsecutiveSaveAsTests { // 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) })+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in },+ onFailed: { _, _, notesRemainAt in failures.append(notesRemainAt) }) } await flow.drain() @@ -292,9 +296,11 @@ struct ConsecutiveSaveAsTests { var failures: [URL?] = [] session.prepareSave(to: firstURL)- flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in },+ onFailed: { _, _, notesRemainAt in failures.append(notesRemainAt) }) session.prepareSave(to: secondURL)- flow.start(session: session, notesManager: manager, onCompleted: { _ in }, onFailed: { failures.append($0) })+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in },+ onFailed: { _, _, notesRemainAt in failures.append(notesRemainAt) }) await flow.drain() // Attempt 1 already migrated the notes onto firstURL and deleted the@@ -322,7 +328,8 @@ struct ConsecutiveSaveAsTests { // 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) })+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in },+ onFailed: { _, _, notesRemainAt in failures.append(notesRemainAt) }) await flow.drain() #expect(session.source == .file(url: thirdURL))@@ -427,16 +434,19 @@ struct ConsecutiveSaveAsTests { try Data("# First".utf8).write(to: first) try Data("# Second".utf8).write(to: second) - flow.currentSession = DocumentSession(clipboardContent: "# Pasted")+ let session = DocumentSession(clipboardContent: "# Pasted")+ flow.currentSession = session // Two exporter callbacks land back to back; the second replaces the- // bookmark slot.+ // bookmark slot. `handleFileSave` prepares each attempt, so the first+ // one has to be read back before the second supersedes it. flow.handleFileSave(.success(first))+ let firstAttempt = try #require(session.pendingSave) flow.handleFileSave(.success(second)) // A stale finalisation for the first destination must not claim the // second destination's bookmark.- flow.completeSaveFlow(first)+ flow.completeSaveFlow(firstAttempt, session: session) let entry = try #require(recentFiles.recentFiles.first { $0.fileName == "first.md" }) let resolved = try recentFiles.withRecentFile(entry) { $0 }
diff --git a/prismTests/DocumentFlowCoordinatorRecentFileTests.swift b/prismTests/DocumentFlowCoordinatorRecentFileTests.swiftindex 042932f6..1e3b3f0e 100644--- a/prismTests/DocumentFlowCoordinatorRecentFileTests.swift+++ b/prismTests/DocumentFlowCoordinatorRecentFileTests.swift@@ -99,7 +99,8 @@ struct DocumentFlowCoordinatorRecentFileTests { @Test("save completion resumes the deferred bookmark open") func saveCompletionExecutesPendingBookmarkOpen() throws { let flow = makeCoordinator()- flow.currentSession = DocumentSession(clipboardContent: "# Unsaved clipboard content")+ let session = DocumentSession(clipboardContent: "# Unsaved clipboard content")+ flow.currentSession = session flow.openRecentFile(makeBookmarkEntry()) #expect(flow.showUnsavedConfirmation == true)@@ -119,7 +120,7 @@ struct DocumentFlowCoordinatorRecentFileTests { try Data("# Saved".utf8).write(to: saveURL) defer { try? FileManager.default.removeItem(at: saveURL) } - flow.completeSaveFlow(saveURL)+ flow.completeSaveFlow(session.prepareSave(to: saveURL), session: session) // The deferred open must now run: the fake bookmark fails to resolve, // which surfaces as the "file no longer available" toast — proof that
diff --git a/docs/agent-notes/notes-system.md b/docs/agent-notes/notes-system.mdindex 54b4f25c..bfb1aee0 100644--- a/docs/agent-notes/notes-system.md+++ b/docs/agent-notes/notes-system.md@@ -99,7 +99,7 @@ Three rules govern `previousDestination`, and none of them is guessable from the - **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.+**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(_:session: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: @@ -108,10 +108,29 @@ Two consequences of settling that are easy to misread, both in Decision 7's Cons 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.+`DocumentFlowCoordinator.pendingSaveBookmark` carries the session and attempt 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). +### Finalising After the Document Has Been Replaced (T-2213)++`isCurrentSaveAttempt` answers "has this document moved past me", never "is this document still open" — and this flow outlives its reader on purpose. So the *coordinator's* half of a finalisation could land while a different document is installed. It read `currentSession` for the recents title, the persisted-state clear and the pending action, and matched the shared bookmark slot on URL alone. A save started for A finishing while B is installed took all four from B.++The rule now: **an effect that speaks about the document on screen runs only while the saving session is still that document; an effect that records what the saving session itself did runs either way.** `completeSaveFlow(_:session:)` and `handleSaveFailed(_:session:notesRemainAt:)` take the identity from the callback and gate on `currentSession === session`; the pending action and the migration-failure alert sit behind that gate. What runs *ahead* of it is not a single "attempt-scoped" set — it differs by path:++- `completeSaveFlow` adds the recents entry (labelled with A's own title) and clears A's own restore point — A really did write that file, and is a file document now.+- `handleSaveFailed` adds **no** recents entry at all, and clears A's restore point only on the `notesRemainAt != nil` branch. A first-attempt failure leaves the document pasted, so its restore point is still the live one and has to stay.++Two things to keep straight:++- **The discriminator is the session *reference*, not `session.id`** — argued once, at the guard in `completeSaveFlow`, and pinned by the two "reused the saving session's id" tests. Do not re-argue it here.+- **`StatePersistence`'s scene binding is one slot shared by whichever session is current.** `clear(_:sessionID:)` wipes it unconditionally *and* deletes the named content file, so a finalisation clearing "the persisted state" for `currentSession` erased the replacement's restore point *and* its pasted text — unrecoverable. `clear(_:ownedBy:)` is the variant these paths use: always deletes that session's own content file, wipes the binding only when its metadata names that session.+- **The notes themselves need no guard, for a reason nothing in the code says.** `NotesManager` is `@State` on `DocumentReaderView`, i.e. one instance per reader — not app-wide and not per-window. A replacement pops that reader and builds a fresh manager, so the late `migrateNotes` rebinds `documentNotes` on an object nobody is looking at any more. That, not any identity check, is why the migration half of a late finalisation is harmless. It is also why the id collision below is the one case that *is* harmful: `migrateNotes` ends with `store.delete(for: sourceIdentifier)` on `clipboard/{sessionID}`, which is app-wide and unguarded, so a restored session sharing that id loses its notes file to another session's migration. Same bound as the `StatePersistence` residual, no guard at all.++Regression coverage: `prismTests/LateSaveFinalisationTests.swift`. Each test installs the replacement from inside `NotesStore.save`, i.e. while the migration is parked, so the ordering is driven rather than timed. Two of them exist purely to separate `===` from an id comparison, because the other six pass under either — if you add a test here, check which of the two it actually pins. `clear(_:ownedBy:)` has its own direct tests in `prismTests/StatePersistenceTests.swift`.++Two clipboard-save bugs that look adjacent but are **not** this defect — both consequences of `didSave(to:)` mutating `source` in place rather than of any late completion: **T-2221** (media windows stay registered under the pre-save `diagramOwnerURL`) and **T-1784** (the web controller and scheme handler keep the clipboard source context they snapshotted at creation).+ ### View Layer Notes availability is gated only on `notesManager.iCloudAvailable` — the `.isFile` check was removed from:
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 026c386f..0f062239 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing.+- Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app. - Opening an SVG image full screen now shows that document's image, rather than one belonging to a different document (T-1866). Two documents that each referred to an SVG by the same relative name — `./diagram.svg` sitting next to each file, say — shared a single entry in the store of rendered previews, because that store was keyed on the name written in the markdown instead of on the file the name actually resolves to. Whichever document you opened first won: opening the second document's diagram showed you the first one's picture, with nothing to indicate it was the wrong one, and it kept doing so for the rest of the session. Rendered SVG previews are now keyed on the resolved image itself — a file's full path, a remote address, or the contents of an inline image — so same-named images in different documents can no longer stand in for one another. Reopening the same image in the same document still comes back instantly from the store, as before, and two documents that embed a byte-for-byte identical inline image do still share one rendered preview, which is correct: identical content renders identically. - A note imported from a document, anchored to a nested list item (a sub-item under a top-level list item), now shows its quoted text and the section it belongs to (T-1871). In the notes pane it appeared with a blank quote and no heading, and it could not be told apart from any other nested-item note in the document. The note itself was always attached to the correct item — only the surrounding context was missing, and it was missing every time the document was opened, not just on a reload. Imported notes are rebuilt from the document on each open, and that rebuild only recognised top-level list-item identifiers, so a nested item's identifier was never matched and the context came back empty. Nested identifiers are now recognised the same way every other part of the app that addresses list items already recognises them, so an imported note on a nested item gets the same context as one on a top-level item. - Refreshing a document opened from a URL no longer leaves a background download running loose after you close it (T-1805). Tapping Refresh started an untracked download with no way to stop it: closing the document, or navigating to a different one, before the download finished let it keep running, and when it finally completed it could still overwrite that URL's title in Recent Files — even though you had already moved on — or race a second refresh for a stale result. Closing the document, or starting another refresh, now cancels the one in flight, and a refresh that has been superseded or cancelled no longer writes its result anywhere.
diff --git a/specs/bugfixes/late-clipboard-save-finalisation/report.md b/specs/bugfixes/late-clipboard-save-finalisation/report.mdnew file mode 100644index 00000000..a78c83c0--- /dev/null+++ b/specs/bugfixes/late-clipboard-save-finalisation/report.md@@ -0,0 +1,301 @@+# Bugfix Report: Late Clipboard Save As Finalisation Mutates a Replacement Session++**Date:** 2026-08-22+**Status:** Fixed+**Ticket:** T-2213++## Description of the Issue++A clipboard Save As has an asynchronous tail: the file is written synchronously by+the exporter, but the session only becomes a file document once its notes have+followed it. `ClipboardSaveFlow` runs that notes work and deliberately outlives its+reader — its `cancel()` is advisory, because abandoning a migration halfway would+leave the notes half-moved, and nothing on the path checks `Task.isCancelled`.++So the finalisation can land after the user has closed the document or opened+another one. `DocumentSession.isCurrentSaveAttempt` (T-1812) guards the session's+own state, but it answers "has this document moved past me", never "is this+document still open". The coordinator's half of the finalisation —+`DocumentFlowCoordinator.completeSaveFlow` / `handleSaveFailed` — read app-wide+state that by then belongs to the replacement document.++**Reproduction steps:**++1. Paste markdown (clipboard session A) and add a note.+2. Start Save As; keep the notes-store write suspended (slow iCloud container).+3. Close A or open another document — clipboard session B is installed and has a+ restore point of its own (a background transition, or a restored session).+4. Let A's migration finish.++**Observed:** A's completion clears "the persisted state" by reading+`currentSession`, which wipes the scene binding *and deletes B's content file* —+B's pasted text is unrecoverable. A's recents entry is labelled with B's title. If+B has a deferred action awaiting its own confirmation dialog, A's completion+executes it. On the failure path, an alert names a file the user is not looking at+and did not just save, and B's deferred action is discarded. Where both saved to+the same file name, A also consumed B's eager bookmark, leaving B's own+finalisation to fall back to a bookmark it can no longer create with security+scope.++**Impact:** High. Silent, unrecoverable loss of a replacement document's pasted+content, plus mislabelled recents entries and spuriously executed navigation.++## Investigation Summary++- **Symptoms examined:** the four coordinator effects named in the ticket —+ persisted state, recents title, bookmark slot, pending action — plus the+ sibling failure path.+- **Code inspected:** `ClipboardSaveFlow`, `DocumentFlowCoordinator`+ (`completeSaveFlow`, `handleSaveFailed`, `handleFileSave`, `clearPersistedState`,+ `activateSession`), `DocumentSession` (`prepareSave`, `isCurrentSaveAttempt`,+ `didSave`), `StatePersistence.clear`, `DocumentReaderView` wiring.+- **Hypothesis ruled out — make `cancel()` real.** `cancel()` is already reached+ on both paths, not just on close: `DocumentReaderView.onDisappear` calls it,+ and a replacement activation pops the reader exactly as a close does, because+ `activateSession` resets `navigationPath`. What makes it advisory is the notes+ work itself, which checks `Task.isCancelled` nowhere and must not — honouring+ cancellation would abandon a migration that has already rewritten the+ in-memory identifier, leaving the notes half-moved. Scoping the effects fixes+ the ordering without touching that contract.++## Discovered Root Cause++**Defect type:** Late completion applied to replaced state (async ordering).++`completeSaveFlow` and `handleSaveFailed` took only a URL. Everything else they+needed — which document was saved, which attempt, whose restore point, whose+bookmark, whose pending action — they inferred from whatever was installed *at+call time*. That inference is sound only while the finalisation is synchronous+with the document, which the design of `ClipboardSaveFlow` guarantees it is not.++`StatePersistence.clear(_:sessionID:)` made the persisted-state case worse than a+stale read: it wipes the shared scene binding unconditionally and deletes the+content file for the id it is handed, so a finalisation reading `currentSession`+deleted the replacement's file rather than its own.++**Why it occurred:** T-1812 correctly identified that two save *attempts* can+overlap and gave the attempt an identity, but that identity stopped at the session+boundary. Nothing carried it across the callback into the coordinator, so the+app-wide half of the flow stayed identity-free.++## Resolution for the Issue++The fix follows the established remedy in this codebase (T-1805, T-1811/T-1812,+T-2089, T-1757): capture the identity before the await, re-check it after, and let+a superseded completion become a no-op rather than a write.++**The discriminator is the session *reference* (`currentSession === session`),**+for the reason argued at the guard in `completeSaveFlow`. Within a session, the+attempt (`PendingSave`: monotonic id + URL) remains the discriminator, and it now+travels with the callback so the coordinator can use it too.++**Changes made:**++- `prism/ViewModels/ClipboardSaveFlow.swift` — both callbacks carry the session+ and attempt they belong to.+- `prism/ViewModels/DocumentFlowCoordinator.swift` —+ `completeSaveFlow(_:session:)` and `handleSaveFailed(_:session:notesRemainAt:)`+ take that identity. Session-scoped effects (the pending action, the failure+ alert) apply only when the session is still installed. What runs ahead of that+ gate differs by path and is not simply "everything attempt-scoped":+ `completeSaveFlow` adds the recents entry with the saving document's own title+ and clears that document's own restore point; `handleSaveFailed` adds no recents+ entry at all, and clears the restore point only on the `notesRemainAt != nil`+ branch, because a first-attempt failure leaves the document pasted and its+ restore point live. The eager bookmark slot now records its owning session and+ attempt rather than only a URL.+- `prism/Services/StatePersistence.swift` — new `clear(_:ownedBy:)`: always+ removes that session's content file, wipes the shared binding only when its+ metadata names that session.+- `prism/Views/DocumentReaderView.swift`, `prism/prismApp.swift` — wiring.++**Residual — the recents entry is not guaranteed.** When the bookmark slot has+been claimed by a newer attempt, `completeSaveFlow` falls back to+`RecentFilesManager.addSavedFile(url:title:)`, which builds the bookmark itself+via `RecentFileEntry(url:)`. That runs after the exporter's security-scoped grant+has expired — the very reason the eager bookmark exists — so it can return nil+and add nothing. Accepted: it is silent, but silently adding an entry that+resolves to a different file is worse, and it is logged. A unit-test host has no+sandbox grant to lose, so the fallback succeeds there and the fourth test's+recents assertion holds.++**Residual — the id collision is not fully closed, and its unchecked half is the+costly one.** `clear(_:ownedBy:)` ownership-checks the scene binding but deletes+the content file unconditionally, so in the one case an id comparison cannot+separate — a session restored via `init(persisted:)` sharing a live session's id+— it takes that session's pasted text, which is the loss this ticket exists to+stop. `NotesManager.migrateNotes` reaches the same collision independently, via+the unguarded `store.delete(for: clipboard/{sessionID})` at the end of a+migration. Both are bounded by the same fact: a stored id re-enters the process+only through `restorePersistedSession`, whose one production caller runs at+window creation, before any save can be in flight. Recorded rather than closed —+closing it needs an identity on disk that outlives the process (T-2213 review 2).++**Alternatives considered:**++- **Make `cancel()` real / check `Task.isCancelled`.** Rejected: it would truncate+ a migration mid-move. `cancel()` is already reached on both close and+ replacement; what it cannot do is stop the notes work safely.+- **Drop a superseded finalisation entirely.** Rejected: the file was written and+ the notes were moved. Discarding the recents entry loses real work; only the+ effects that belong to the *installed* document need withholding.+- **Compare `session.id`.** Rejected — reasoning at the guard in+ `completeSaveFlow`.++## Regression Test++**Test file:** `prismTests/LateSaveFinalisationTests.swift`++Eight tests, each driving the ordering rather than the end state: the replacement+document is installed from inside `NotesStore.save`, i.e. while the migration is+suspended, so the late completion is deterministic. The replacement is installed+through `restorePersistedSession`, the app's own path for a clipboard session that+has a restore point.++One effect per test, over the four the ticket names plus the failure path:++- `lateCompletionKeepsTheReplacementRestorePoint` — the saving document is given a+ restore point of its own first (the one a background transition writes while it+ is still pasted). Without that the "its own restore point went" half asserts+ about a content file that never existed, and holds with the fix reverted.+- `lateCompletionUsesTheSavingDocumentsTitle`+- `lateCompletionDoesNotRunTheReplacementsPendingAction`+- `lateCompletionDoesNotConsumeTheReplacementsBookmark`+- `lateFailureDoesNotReportOverTheReplacement`++Two pin the discriminator itself. Every test above passes just as well with the+guards comparing `session.id`, and that is not an artefact of the fixture — see+`completeSaveFlow`'s doc comment for why the two almost always agree. These build+the one case that separates them, a restored document re-using a live session's+id:++- `lateCompletionSeparatesADocumentThatReusedTheSavingSessionsID`+- `lateFailureSeparatesADocumentThatReusedTheSavingSessionsID`++One covers the branch that still writes while the saving session is *not*+installed — `handleSaveFailed`'s `notesRemainAt != nil` settle, which needs a+two-attempt chain to reach:++- `lateMidChainFailureClearsOnlyTheSavingDocumentsRestorePoint`++`StatePersistence.clear(_:ownedBy:)` also has four direct tests in+`prismTests/StatePersistenceTests.swift` (clears its own slot; leaves another+session's slot intact; empty slot; undecodable data).++**Run command:**++```+xcodebuild test-without-building -project prism.xcodeproj -scheme prism \+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -configuration Debug \+ -derivedDataPath ./DerivedData \+ -resultBundlePath DerivedData/TestResults.xcresult -testPlan prism \+ -only-test-configuration "en (base)" -parallel-testing-worker-count 1 \+ -only-testing:prismTests/LateSaveFinalisationTests \+ -only-testing:prismTests/StatePersistenceTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/ViewModels/ClipboardSaveFlow.swift` | Callbacks carry session + attempt |+| `prism/ViewModels/DocumentFlowCoordinator.swift` | Identity-scoped finalisation, owned bookmark slot |+| `prism/Services/StatePersistence.swift` | `clear(_:ownedBy:)` |+| `prism/Views/DocumentReaderView.swift` | Callback types |+| `prism/prismApp.swift` | Wiring |+| `prismTests/LateSaveFinalisationTests.swift` | New regression suite |+| `prismTests/StatePersistenceTests.swift` | Direct tests for `clear(_:ownedBy:)` |+| `prismTests/ConsecutiveSaveAsTests.swift` | Updated to new signatures |+| `prismTests/DocumentFlowCoordinatorRecentFileTests.swift` | Updated to new signatures |+| `docs/agent-notes/notes-system.md` | Documented the scoping rule |+| `CHANGELOG.md` | User-facing entry |++## Related Tickets Assessed++- **T-2221** (saving a clipboard document re-keys media-window ownership) — *not*+ the same defect. It is a synchronous consequence of `didSave(to:)` mutating+ `source` in place: `diagramOwnerURL` is derived from `source`, so already-open+ media windows stay registered under the old key. No async ordering is involved+ and no guard added here addresses it; the fix it needs is a+ `reassignOwnership(from:to:)` on the window managers. Left open.+- **T-1784** (clipboard save keeps web image resolution in clipboard mode) — *not*+ the same defect either, and the same shape as T-2221: the web controller and+ scheme handler snapshot the source context at creation and are not rebuilt when+ `didSave` mutates `source`. Left open.++Both are consequences of the in-place `source` mutation, not of a late completion.+Folding them in here would have meant a second, unrelated change (window-manager+re-keying / controller rebuild) inside a concurrency fix.++## Prevention++- Any callback invoked after an `await` on a per-document flow should carry the+ document identity it belongs to, not let the receiver infer it from installed+ state. `RemoteRefreshFlow` (T-1805) and this flow are now the two examples.+- When picking a discriminator, check it against the specific transition being+ detected, and write the test that separates it from the discriminator you did+ not pick. Both were nearly missed here: the first draft of this report argued+ the choice from a transition that does not distinguish them at all, and the+ first regression suite passed under either.+- `StatePersistence`'s scene binding is a single slot shared by whichever session+ is current. Clearing it on behalf of a named session must check ownership.++## Related++- T-1812 — consecutive Save As attempt identity (the same guard, one scope in)+- T-1805 — remote refresh outliving its document+- T-2089, T-1811 — generation/identity guards in `NotesManager`+- `docs/agent-notes/notes-system.md` — Clipboard Notes Support++## Mutation Evidence++Each guard was reverted on its own, so every result names the one production line+it belongs to rather than "something in the fix". Signatures were kept so the+suite still compiles.++| Mutation | Result |+|----------|--------|+| None (the fix) | `total=71 passed=71 failed=0` — `LateSaveFinalisationTests`, `StatePersistenceTests`, `ConsecutiveSaveAsTests`, `DocumentFlowCoordinatorRecentFileTests`, `SaveFlowIntegrationTests`, `StatePersistenceIntegrationTests` |+| All three `clearPersistedState(ownedBy: session)` → `clearPersistedState()` | `total=8 passed=6 failed=2` — `lateCompletionKeepsTheReplacementRestorePoint`, `lateMidChainFailureClearsOnlyTheSavingDocumentsRestorePoint` |+| `handleSaveFailed`'s `if notesRemainAt != nil { clearPersistedState(ownedBy:) }` branch deleted | `total=8 passed=7 failed=1` — `lateMidChainFailureClearsOnlyTheSavingDocumentsRestorePoint` |+| Both `guard currentSession === session` → `currentSession?.id == session.id` | `total=8 passed=6 failed=2` — `lateCompletionSeparatesADocumentThatReusedTheSavingSessionsID`, `lateFailureSeparatesADocumentThatReusedTheSavingSessionsID` |+| `clear(_:ownedBy:)`'s ownership check dropped (wipe the binding unconditionally, i.e. `clear(_:sessionID:)` again) | `total=26 passed=22 failed=4` — `clearOwnedByLeavesAnotherSessionsSlotIntact`, `clearOwnedByLeavesUndecodableDataAlone`, `lateCompletionKeepsTheReplacementRestorePoint`, `lateMidChainFailureClearsOnlyTheSavingDocumentsRestorePoint` |++Two rows are worth reading twice. The id-comparison row: the other six+`LateSaveFinalisationTests` pass under it, which is why the two collision tests+exist — the original five did not pin the discriminator at all (T-2213 review).+And the last row: dropping the ownership check makes `clear(_:ownedBy:)`+behaviourally identical to `clear(_:sessionID:)`, i.e. it restores the data-loss+bug outright, so it is caught four ways — by both direct tests that describe the+check and by the two flow tests whose restore points it destroys.++All runs: iOS Simulator (iPhone 17 Pro), `-testPlan prism`,+`-only-test-configuration "en (base)"`, `-parallel-testing-worker-count 1`,+counts read from the result bundle via `Tools/check-test-results.sh`, exit status+reconciled against the bundle rather than trusted on its own.++**Note on the environment.** These runs were taken against an isolated+`-derivedDataPath` and a dedicated simulator id, not the shared ones. Concurrent+`xcodebuild` runs on this machine wedge the simulator (14 minutes, zero tests, no+booted device) and starve it of swap, which shows up as exit 137 or 65 with no+test-case failures at all. A run that ends without a readable test summary is not+a result.++**One observed flake, not fixed here.** Under that load,+`ConsecutiveSaveAsTests.failedMigrationMidChainSettlesOnTheNotesFile` failed once+at `ConsecutiveSaveAsTests.swift:324` — `manager.documentNotes` was nil after the+settle reload — and passed on the very next pass of the same run. It is a T-1812+test in code this change does not touch; it passed 3/3 in isolation with these+new tests removed, and the full six-suite set is 71/71 on two consecutive runs+with them present. Nothing shared could carry it between suites:+`NotesManager.loadGeneration` is a private instance var and each test owns its+own store. Recorded rather than dismissed — if it recurs on an unloaded machine+it is a real defect in that test's reload, and this is the first sighting.++**Note on the destination.** The macOS test host could not be launched on this+machine at the time: `-destination 'platform=macOS'` runs died with+`Timed out after 120.0s while initiating control session with daemon` /+`The test runner hung before establishing connection`, ~705s wall clock and zero+tests executed each time — the known multi-lane contention signature, not a+result. The same suites run clean on the iOS Simulator, which uses a different+test daemon. Nothing in the changed code or the new tests is macOS-specific.
The review's mutation runs were taken before the rebase; the verdict was re-established afterwards. Post-rebase the branch is 6 commits, +1137/-59, and the six-suite set is exit 0, total 71 passed 71 failed 0 with make lint clean. The corrected CHANGELOG wording, the rewritten clear(_:ownedBy:) residual and both recorded facts all survived the rebase — verified in the working tree, not assumed.
belongs(to:) is the third site spelling the discriminator and the only one round two did not pin. Mutating it to self.session?.id == session.id leaves all 71 green, because lateCompletionDoesNotConsumeTheReplacementsBookmark gives the replacement a fresh id. Unreachable in production by the same one-caller argument that bounds the recorded residual — but the branch's own standard is that the argument is not enough, which is why the two collision tests exist.
migrateNotes finishes with store.delete(for: sourceIdentifier), and the source is clipboard/{sessionID}. A restored session sharing that id loses its notes file to another session's migration. Same bound as the recorded StatePersistence residual, same severity, and no guard on that path at all. Recorded in the agent note by this review; not otherwise mentioned anywhere.
It passes only because both attempts are PendingSave(id: 1, url: destination), so dropping the session check makes belongs match. Read as coverage of the session ownership it is misleading; read as coverage of attempt equality it is exact.
Not re-raised as blockers, but still true: the duplicated PersistedSessionMetadata decode in StatePersistence; the duplicated clearPersistedState(ownedBy:) either side of handleSaveFailed's guard, placed below it where completeSaveFlow places its equivalent above; activateSession not evicting a stale bookmark; handleFileSave's new guard let session = currentSession else { return } swallowing a successful export with no log; and ReplacementHookStore being the fourth near-identical notes-store double in prismTests.