Second pre-push review. Review #1 returned Needs fixes on one blocker — the new ownership predicate dropped non-emptiness, so an emptied clipboard container took the migrate branch and migrateNotes' atomic store.save overwrote the destination's own notes with nothing. Commit 145f1687 addresses it. This pass re-audits the fix, then goes after the documentation that describes it.
The production fix is correct. The three findings this round are all documentary, and all three were wrong in a way that would mislead the next maintainer: a self-inverted sentence in the agent notes, verification the bugfix report claims but never performed, and a CHANGELOG entry announcing a bug that never shipped. All three are fixed in the working tree.
ClipboardSaveFlow.swift:161-162 reads notesManager.hasNotes && notesManager.notesBelongToSaveChain(sessionID:previousDestination:). migrateNotes has exactly one production call site (ClipboardSaveFlow.swift:171), inside that if. There is no third path.guard documentNotes?.identifier == targetIdentifier else { return true } is textually identical to origin/main (moved from line 1144 to 1189 by the added comments). Its new doc comment correctly records why it must stay narrower than the chain predicate (T-2231).emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes goes red if hasNotes is dropped, and stays green if notesBelongToSaveChain is dropped — with an empty container hasNotes is already false, so the load branch is taken either way. The other half is pinned by the sibling test. The pair is complete; the report claimed a symmetry it does not have.origin/main the gate is hasNotes alone, so an empty container already takes the load branch. No user was ever exposed. Sentence removed.notesBelongToSaveChain-alone as the already-fixed residual and hasNotes-alone as the original shape — both inverted, and contradicting line 86 of the same file. A maintainer following it would drop precisely the half that prevents data loss.ConsecutiveSaveAsTests must be run green before pushing.Needs fixes — one verification step, no code changes
The gate at ClipboardSaveFlow.run is hasNotes && notesBelongToSaveChain(…), there is exactly one production path into migrateNotes and it sits inside that gate, and the post-save identity guard is byte-identical to origin/main. make lint (0 violations), make build-macos (Build Succeeded, no warnings) and make verify-test-isolation all pass. The blocker from review #1 is genuinely closed, and I verified the empty-container premise at the source: both deleteNote and clearResolved publish a non-nil empty container and persist it, so "reachable with no race" is accurate.
The one thing standing between this and a push is that the two new regression tests have never been run. The report records the mutation check as cancelled after 30 minutes of machine contention and the targeted run as a host-launch failure with zero tests executed. This project keeps Tools/check-test-results.sh specifically because a zero-test run reports success (T-1983); pushing a regression test that has never been observed green is that hazard wearing a different hat. I could not close it — no xcodebuild test runs were permitted this round.
Three documentary findings were raised and all three are fixed in the working tree (markdown only — I touched no Swift). Nothing else blocks.
7446605e Fix T-1812: consecutive Save As can still leak a superseded destination's notes 145f1687 Fix T-1812: gate note migration on ownership AND non-emptiness working-tree Editorial corrections applied in this review When you paste text into Prism it becomes a document with no file behind it. If you attach notes to it and then use Save As, those notes have to follow the document to the file you picked. Prism does that move in the background, which means the Save button is still live — so you can hit Save As a second time before the first move has finished.
Prism has to decide one thing at the start of each save: are the notes currently in memory mine to move? Getting that wrong in either direction loses something.
The previous version of this fix replaced the old check with a new one that asked "do these notes carry my name?" That is the right question, but it left out a second one: "are there actually any notes?" If you added a note to your pasted document and then deleted it, Prism kept an empty note record with your name on it. The new check waved that empty record through as "mine", the save moved it onto the file you were saving over, and because the save writes the whole record at once, the notes that file already had were replaced with nothing.
The fix asks both questions: are there notes and are they mine. Two regression tests keep it that way — one for each question. Deleting either test, or either half of the check, would let one of the two bugs back in.
ClipboardSaveFlow owns the asynchronous half of a clipboard Save As: the exporter writes the file synchronously, then the flow moves the notes from the clipboard-keyed identifier onto the file identifier before DocumentSession transitions to .file. Attempts are serialised (a new attempt awaits the previous one), and everything after an await is gated on session.isCurrentSaveAttempt.
The branch point at the top of run decides migrate-vs-load. Round one of this fix replaced hasNotes there with a new ownership predicate:
func notesBelongToSaveChain(sessionID: UUID, previousDestination: URL?) -> Bool {
guard let notes = documentNotes else { return false }
let expected = identifierResolver.resolve(forClipboardSession: sessionID)
let superseded = previousDestination.map { identifierResolver.resolve(from: $0) }
return notes.identifier == expected || notes.identifier == superseded
}hasNotes is documentNotes?.notes.isEmpty == false — it had been quietly doing two jobs: an ownership proxy and a non-emptiness check. Swapping in a pure ownership predicate kept the first job and silently dropped the second. deleteNote and clearResolved both publish a non-nil, empty container and persist it; nothing nils it. That container's identifier still matches, so the predicate alone accepts it, and NotesStore.save writes the whole container atomically — the destination's existing notes are replaced with nothing before the clipboard record is deleted.
The landed gate is the conjunction, with the rationale for each half written out at the call site. The alternative — make migrateNotes return a result enum so the callee's answer drives the caller's branch instead of a pre-flight guess — was not taken. That leaves the safe-use rule enforced by a comment, on an API whose misuse is silent data loss. Worth weighing, but not a defect in what landed: the conjunction is correct and both halves are now pinned by tests.
The branch is not "clipboard notes exist / don't exist", though five comments and two doc paragraphs still call branch 2 "the no-notes branch". It is a three-way classification collapsed into two branches:
State of documentNotes | Branch | Why |
|---|---|---|
| Owned, non-empty | migrate | this chain's notes; move them |
| Foreign (any content) | load | an earlier attempt loaded a destination's own notes for display; never ours to move, and migrateNotes would refuse-and-report-success, stranding the manager on a borrowed identity |
| Owned, empty | load | identifier matches, but migrating it has store.save atomically replace the destination's notes with nothing |
Each of the two load rows is a separate bug shape, and each is caught by exactly one half of the conjunction. That is why neither half alone is the gate, and why two tests are required rather than one.
The report claimed the two reopening tests mutation-check the gate symmetrically. They do not, and cannot:
hasNotes: the empty container's identifier matches, migrate branch is taken, migrateNotes passes its own guard, store.save writes the empty container over the destination. emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes fails on both the in-memory content assertion and the storedNotes assertion. Red.notesBelongToSaveChain: hasNotes is already false for an empty container, so the load branch is taken — byte-identical behaviour to the fixed code. Same test is green. The half is instead pinned by supersededAttemptsPreexistingNotesDoNotLeakIntoTheNextDestination, where hasNotes is true (attempt 1's adopted notes) and only the predicate can reject.The pair is complete. The claim of per-test symmetry was not, and the report has been corrected to state the matrix as intent rather than evidence.
loadNotes that previously took the (inert) migrate branch: hasNotes true with a foreign container. loadNotes is unguarded by attempt currency, so in principle a superseded attempt could clobber another document's in-memory notes. In practice it converges: DocumentReaderView holds notesManager and saveFlow as sibling @State, and .navigationDestination(for: UUID.self) keys the reader on the session id, so a flow that outlives its reader writes into a NotesManager no view still holds. Within one session, loadGeneration ordering plus the reader's parseRevision reload settle on the final destination. No regression.hasNotes was already false there), and the residue is an empty file. Not a regression.prism/ViewModels/ClipboardSaveFlow.swift
Why it matters. This is the whole fix. Six characters of logic (`hasNotes &&`) separate correct behaviour from silently replacing a destination's notes with an empty container. Confirmed: this is the only production path into `migrateNotes`, and it sits inside the gate.
What to look at. ClipboardSaveFlow.swift:161-162 (call site), :141-160 (rationale)
prism/Services/NotesManager.swift
Why it matters. Extracts the identity test that `migrateNotes` already performed so the caller's gate and the callee's guard cannot disagree about what the chain owns. The caller's pre-check and the callee's re-check are now the same expression, which is the property that makes the refuse-and-report-success contract safe.
What to look at. NotesManager.swift:1107-1112 (predicate), :1152 (migrateNotes' guard now delegates)
prism/Services/NotesManager.swift
Why it matters. The obvious cleanup after extracting a shared predicate is to use it everywhere, and here that would be a bug. Post-save, only the target identifier is acceptable; the clipboard and previousDestination identities the chain predicate also accepts belong to a different in-flight load, and rebinding onto one reproduces T-2231 from the other side. Verified byte-identical to origin/main.
What to look at. NotesManager.swift:1180-1189 (comment), :1189 (guard, unchanged)
prismTests/ConsecutiveSaveAsTests.swift
Why it matters. Each test catches exactly one half of the conjunction and neither catches both — a property the bugfix report described as symmetric and which I verified statically is not. Both tests must survive for the gate to be pinned.
What to look at. ConsecutiveSaveAsTests.swift:390-441 (predicate half), :443-500 (hasNotes half)
The landed design keeps migrateNotes -> Bool and adds a caller-side pre-check that duplicates the callee's guard. The alternative — a result enum (.migrated / .notThisChain / .nothingToMigrate / .failed) driving the caller's branch — removes the need for the caller to guess correctly at all. Not taken; the caller contract lives in prose instead.
The 20-line comment at ClipboardSaveFlow.swift:141-160 gives each half its own failure mode. That is the right shape for a check whose history is a record of one half being dropped — a comment saying 'both are needed' would not survive the next refactor, whereas one naming what each prevents might.
The post-save guard was not touched; only its comment grew. Documenting why a line stayed narrow is unusual and correct here — the extraction in the same commit is what creates the temptation to widen it.
Not mentioned in either commit message. The merged bullet loses no content and is in the right section. Three duplicate T-1840 bullets, three T-1779, three T-1811 and two T-1951 remain immediately below, untouched — pre-existing and out of scope, but the file needs a dedup pass.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | docs/agent-notes/notes-system.md:97 | The new gate bullet inverts both bug shapes. It reads 'Gating on that alone [notesBelongToSaveChain] was the residual T-1812 shape below. Gating on hasNotes alone was the original T-1812 shape' — the reverse of the truth on both counts, and directly contradicting line 86 of the same file ('presence alone was the T-1812 residual, and ownership alone was a data-loss blocker'). hasNotes-alone was the residual (round one's gate); notesBelongToSaveChain-alone is the data-loss blocker; the original T-1812 shape was migrateNotes' clipboard-only guard plus unserialised attempts. This is the paragraph a maintainer reads before touching the gate, and following it would mean dropping precisely the half that prevents data loss. | Rewritten to attribute each shape correctly, keeping the mechanism sentence for each: hasNotes-alone identified as the original fix's gate and the residual shape, notesBelongToSaveChain-alone as the data-loss blocker. |
| major | specs/bugfixes/consecutive-save-as-cross-wiring/report.md:70-72, :249-255 | The report asserts verification it did not perform, and contradicts itself doing so. Both places state the reopening tests were mutation-checked ('confirming the test fails ... before restoring the fix'; 'were mutation-checked directly against the fix they pin'), while the same report's Verification section records the mutation run as cancelled after 30 minutes of contention without reaching a verdict, and the targeted run as a host-launch failure with zero tests executed. Neither reopening test has been observed passing, let alone failing under mutation. An overclaimed verification is worse than an absent one: it stops the next person re-running it. | Both passages rewritten to state the mutation matrix as intent and explicitly point at Verification for the outcome. Also corrected the asymmetry the report asserted — the empty-container test catches only the dropped-hasNotes mutation, not both (see finding TC-1). |
| major | CHANGELOG.md:41 | The entry's final sentence announces a fix for a defect that never shipped: 'Saving As over an existing file after adding, then deleting, a note ... no longer wipes out that file's own notes.' That empty-container data loss was introduced by this branch's first commit and fixed by its second. On origin/main the gate is hasNotes alone, so an empty container already takes the load branch, and migrateNotes has exactly one production caller — there is no other route in. No released version was affected. Release notes feed prism-release-prep, so this would have reached users as a bug report about a bug they never had. | Sentence removed. Its record belongs in the bugfix report, where it already is. |
| minor | CHANGELOG.md:41 (penultimate sentence) | Mis-attributes which destination supplied the borrowed notes: 'because the destination it landed on already had its own notes' — the notes came from the *first* attempt's destination, not the second's. The sentence as written describes a save picking up notes from the file it is saving to, which is not the bug. | Reworded to attribute the loaded notes to the earlier attempt's destination. |
| minor | docs/agent-notes/notes-system.md:116 | 'one test per guard (all mutation-checked)' is no longer true on either count — the suite is 12 tests against 4 guards, and the two reopening tests were not mutation-checked. | Updated to 12 tests, at least one per guard, with the round-one/reopening mutation-check status stated separately. |
| minor | specs/bugfixes/consecutive-save-as-cross-wiring/report.md:23-27, :311 | The Reopening section's first statement of the fix ('replace hasNotes with notesBelongToSaveChain') describes the intermediate commit rather than what landed; it is corrected two bullets later, but as the opening statement it misleads. Separately, ':311 Regression tests pass (10 tests...)' now sits above a table of 12. | Fix statement now names the landed gate explicitly and flags the replacement as the first commit's shape; test count qualified as 10-at-the-time / 12-after. |
| major | Verification — prismTests/ConsecutiveSaveAsTests.swift | Neither new regression test has ever executed. The report records the mutation build compiling but its run cancelled, and the targeted run failing at test-host launch with total=1 passed=0 failed=1, zero tests executed. This project maintains Tools/check-test-results.sh precisely because a zero-test run reports success (T-1983, months of fictional CI green). A regression test that has never been observed green is not yet a regression test. | Not fixable in this review — no xcodebuild test runs were permitted (machine contention). Must be closed before pushing: run ConsecutiveSaveAsTests to a real verdict on a quiet machine, ideally with both mutations. This is the only must-do. |
| minor | Design — NotesManager.swift:1147-1152 + ClipboardSaveFlow.swift:161-162 (CQ-1) | migrateNotes' Bool conflates 'migrated' with 'refused — not this chain's'. The fix works around that by having the caller pre-evaluate the same predicate the callee then re-evaluates, with the safe-use rule ('Callers must make the same check BEFORE deciding to call this at all'; 'must additionally require hasNotes') enforced only by comment. The branch's own history is a record of that rule being broken once — the second commit exists because the first dropped hasNotes — and the failure mode is silent data loss through an atomic store.save. Two ways out: return a result enum so the callee's answer drives the caller's branch, or expose a single un-misusable hasMigratableNotes(sessionID:previousDestination:) = hasNotes && identity, keeping the identity-only form private. | Not changed — what landed is correct and both halves are now test-pinned. Raised as a design item for the author to weigh, not a defect. If left as-is, the conjunction and its per-half rationale are the right mitigation. |
| minor | Terminology drift — 6 sites | Branch 2 is still called 'the no-notes branch' at ClipboardSaveFlow.swift:17-18, :65, :69, :143, NotesManager.swift:1083, :1134 and notes-system.md:101, while notes-system.md:86 now explicitly says it is not that any more (it is 'not ours, or ours-but-empty'). Internally consistent across all six, so naming drift rather than a factual error — but lines 86 and 101 of the same file now read as contradicting each other. | Left alone — renaming across six production comments is beyond an auditor's editorial remit. Worth a follow-up pass; 'the load branch' would do. |
| nit | prism/Services/NotesManager.swift:1137, :1152-1155 | Two small doc/log inaccuracies in production comments. (a) '- Returns: true if migration succeeded or there were no notes to migrate' omits the third case the caller contract now depends on — refusal of a container that is not this chain's also returns true. (b) The guard-else recomputes expectedIdentifier solely to log it, and the message still reads "don't match expected clipboard session" although the predicate now also accepts previousDestination — a chain-step refusal logs a mismatch against an identifier that was never the only candidate. | Left alone deliberately — I kept my edits out of Swift files so the audit and the fix stay separable. Both are one-line author fixes. |
| nit | prismTests/ConsecutiveSaveAsTests.swift:390-500 | Test setup duplication. The four-line clipboard-session preamble is now written out three times and the 7-argument BlockNote initialiser four times — once via an ad-hoc nested func note(_:), otherwise inline — while the suite already has makeSessionWithClipboardNote, which differs only by creating a note. | Left alone — tests are not touched in an audit, and the duplication is legible. A suite-level makeSession(store:) and makeNote(_:blockId:) would pay for themselves at the next added test. |
| nit | CHANGELOG.md — pre-existing duplicates | The branch correctly collapses main's two duplicate T-1812 bullets. Immediately below sit three duplicate T-1840 bullets, three T-1779, three T-1811 and two T-1951, all pre-existing. | Out of scope for this branch. Flagged for a dedup pass — the branch is already editing this exact region, so it is the cheapest place to do it. |
Click to expand.
diff --git a/prism/ViewModels/ClipboardSaveFlow.swift b/prism/ViewModels/ClipboardSaveFlow.swiftindex 492549d9..5ca1d603 100644--- a/prism/ViewModels/ClipboardSaveFlow.swift+++ b/prism/ViewModels/ClipboardSaveFlow.swift@@ -138,7 +138,28 @@ final class ClipboardSaveFlow { ) async { let migrationSource = previousDestination - if notesManager.hasNotes {+ // Both halves are load-bearing, and neither alone is the right gate:+ //+ // - `hasNotes` alone: the no-notes branch below loads a destination+ // file's own pre-existing notes so they can be shown, which makes+ // `hasNotes` true for the *next* attempt without those notes+ // belonging to this chain. Migrating on `hasNotes` alone hands+ // `migrateNotes` a container it will (correctly) refuse, and since+ // a refusal reports success, this attempt would finalise believing+ // it moved its notes when it never loaded its own destination's at+ // all (T-1812).+ // - `notesBelongToSaveChain` alone: it only asks whether the loaded+ // container's *identifier* is this chain's, never whether it has+ // anything in it. `deleteNote`/`clearResolved` can leave a non-nil,+ // empty container under the clipboard identifier — reachable with+ // no race: paste, add a note, delete it. That container's+ // identifier still matches, so the predicate alone would migrate+ // it — and `migrateNotes`' `store.save` writes atomically, so an+ // existing destination's own notes would be silently replaced with+ // nothing before the clipboard record is deleted. `hasNotes` is+ // what rules that out.+ if notesManager.hasNotes+ && notesManager.notesBelongToSaveChain(sessionID: session.id, previousDestination: migrationSource) { // This attempt is about to move the chain's notes onto its own URL, // which is where the next attempt has to pick them up. Recorded // ahead of the `await` and unconditionally: a superseded attempt
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex a4d664c9..b72d5edc 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -1075,6 +1075,42 @@ final class NotesManager { // MARK: - Migration + /// Whether the currently loaded notes are ones this save chain actually+ /// owns: either the clipboard session's own notes, or notes an earlier+ /// attempt in this chain already migrated to `previousDestination`.+ ///+ /// `hasNotes` alone is not enough to decide whether an attempt should+ /// migrate: the no-notes branch of a save chain *loads* a destination+ /// file's own pre-existing notes so they can be shown, which makes+ /// `hasNotes` true without those notes belonging to this chain. Deciding+ /// to migrate on `hasNotes` alone hands `migrateNotes` a container it+ /// will (correctly) refuse — it matches neither identifier — and,+ /// because refusal returns success, the caller mistook "nothing to do"+ /// for "done" and never loaded the real destination's own notes,+ /// stranding the previous destination's notes in memory instead (T-1812).+ ///+ /// Identity only — deliberately says nothing about whether the container+ /// has any notes in it. `deleteNote`/`clearResolved` can leave a non-nil,+ /// empty container under an identifier this predicate accepts (e.g. after+ /// creating and then deleting the chain's only note), and that container+ /// still "belongs" to the chain in the sense tested here. Callers that+ /// gate a *write* on this — `ClipboardSaveFlow` migrating onto a+ /// destination — must additionally require `hasNotes`, or an emptied+ /// container silently replaces the destination's own notes via+ /// `migrateNotes`' atomic `store.save`.+ ///+ /// - Parameters:+ /// - sessionID: The clipboard session driving this save chain.+ /// - previousDestination: Destination an earlier attempt in this chain+ /// migrated these notes to, if any. See `migrateNotes`'s parameter of+ /// the same name for the caller contract.+ func notesBelongToSaveChain(sessionID: UUID, previousDestination: URL?) -> Bool {+ guard let notes = documentNotes else { return false }+ let expectedIdentifier = identifierResolver.resolve(forClipboardSession: sessionID)+ let supersededIdentifier = previousDestination.map { identifierResolver.resolve(from: $0) }+ return notes.identifier == expectedIdentifier || notes.identifier == supersededIdentifier+ }+ /// Migrate notes from a clipboard session to a file-based identifier. /// /// Handles the clipboard-to-file transition when a user saves a pasted document.@@ -1108,9 +1144,13 @@ final class NotesManager { // Validate that loaded notes match the expected clipboard session, or // the destination a superseded attempt on this session moved them to.- let expectedIdentifier = identifierResolver.resolve(forClipboardSession: sessionID)- let supersededIdentifier = previousDestination.map { identifierResolver.resolve(from: $0) }- guard notes.identifier == expectedIdentifier || notes.identifier == supersededIdentifier else {+ // Callers must make the same check (`notesBelongToSaveChain`) BEFORE+ // deciding to call this at all — a mismatch here means the caller+ // handed over a container that was never this chain's to migrate, and+ // "skip, report success" leaves it to the caller to load the real+ // destination's own notes instead (T-1812; see `ClipboardSaveFlow`).+ guard notesBelongToSaveChain(sessionID: sessionID, previousDestination: previousDestination) else {+ let expectedIdentifier = identifierResolver.resolve(forClipboardSession: sessionID) logger.info("migrateNotes skipped — loaded notes (\(notes.identifier.path, privacy: .public)) don't match expected clipboard session (\(expectedIdentifier.path, privacy: .public))") return true }@@ -1140,7 +1180,12 @@ final class NotesManager { // different document may have replaced the in-memory notes while this // one was parked in the store. Everything below rebinds document-wide // identity, so it must only run while these notes are still the ones- // this migration moved (T-1812).+ // this migration moved (T-1812). Deliberately narrower than+ // `notesBelongToSaveChain`: post-save, only the *target* identifier is+ // acceptable — the clipboard/previousDestination identities that+ // predicate also accepts belong to a different in-flight load, and+ // rebinding onto one of those would reproduce T-2231 from the other+ // side (do not substitute the chain predicate here). guard documentNotes?.identifier == targetIdentifier else { return true } // Update cached document identity so imported-note resolved toggles
diff --git a/prismTests/ConsecutiveSaveAsTests.swift b/prismTests/ConsecutiveSaveAsTests.swiftindex f060d500..b2ea4390 100644--- a/prismTests/ConsecutiveSaveAsTests.swift+++ b/prismTests/ConsecutiveSaveAsTests.swift@@ -375,6 +375,130 @@ struct ConsecutiveSaveAsTests { #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes .map(\.content) == ["Pre-existing note"]) #expect(await store.storedNotes[resolver.resolve(from: secondURL).path] == nil)++ // Reopened bug (residual, Codex 2026-08-15): attempt 2 saw `hasNotes`+ // true (attempt 1's adopted notes were still in memory), took the+ // migrate branch, had `migrateNotes` correctly refuse a container that+ // matches neither the clipboard nor a previousDestination it wrote —+ // and then treated that refusal's "success" as "done", finalising the+ // session onto secondURL while `NotesManager` still held firstURL's+ // identity and content. secondURL has no notes of its own, so the+ // correct end state is no notes at all — not attempt 1's leftovers.+ #expect(session.source == .file(url: secondURL))+ #expect(manager.documentNotes == nil)+ }++ @Test("a superseded attempt's pre-existing notes do not leak into the next destination's own")+ func supersededAttemptsPreexistingNotesDoNotLeakIntoTheNextDestination() async {+ let store = MockNotesStore()+ let resolver = DocumentIdentifierResolver()+ let session = DocumentSession(clipboardContent: "# Pasted")+ let block = MarkdownBlock.paragraph(markdown: "Pasted")+ session.parsedBlocks = [block]+ let manager = NotesManager.makeForTesting(store: store)++ // Both destinations already have notes of their own — nothing to do+ // with this clipboard session, which has none.+ func note(_ content: String) -> BlockNote {+ BlockNote(+ blockId: block.id,+ contextQuote: "Pasted",+ content: content,+ status: .active,+ createdAt: Date(),+ modifiedAt: Date()+ )+ }+ await store.preload(DocumentNotes(+ identifier: resolver.resolve(from: firstURL),+ displayName: firstURL.lastPathComponent,+ notes: [note("First's own note")]+ ))+ await store.preload(DocumentNotes(+ identifier: resolver.resolve(from: secondURL),+ displayName: secondURL.lastPathComponent,+ notes: [note("Second's own note")]+ ))++ // Attempt 1 saves over firstURL and, having no notes to migrate,+ // adopts that file's own notes. It is superseded before finalising,+ // so attempt 2 runs with firstURL's notes still in `NotesManager`.+ await runOverlappingSaves(session: session, manager: manager)++ // Reopened bug (residual, Codex 2026-08-15): attempt 2 saw `hasNotes`+ // true from attempt 1's adopted notes, took the migrate branch instead+ // of loading its own destination, `migrateNotes` refused the+ // mismatched container and reported success, and the session settled+ // on secondURL while `NotesManager` kept firstURL's identity and+ // content. The manager must end up on secondURL's own notes instead.+ #expect(session.source == .file(url: secondURL))+ #expect(manager.documentNotes?.identifier == resolver.resolve(from: secondURL))+ #expect(manager.documentNotes?.notes.map(\.content) == ["Second's own note"])++ // Neither file's stored notes were touched — this chain never wrote+ // either of them.+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes+ .map(\.content) == ["First's own note"])+ #expect(await store.storedNotes[resolver.resolve(from: secondURL).path]?.notes+ .map(\.content) == ["Second's own note"])+ }++ @Test("an emptied clipboard note container does not overwrite the destination's own notes")+ func emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes() async throws {+ let store = MockNotesStore()+ let resolver = DocumentIdentifierResolver()+ let session = DocumentSession(clipboardContent: "# Pasted")+ let block = MarkdownBlock.paragraph(markdown: "Pasted")+ session.parsedBlocks = [block]+ let manager = NotesManager.makeForTesting(store: store)++ // Create a clipboard note, then delete it. `deleteNote` leaves a+ // non-nil, empty container under the clipboard identifier and+ // persists it — nothing nils it — so it survives to this save+ // attempt exactly as it would after a real paste/add/delete sequence.+ await manager.createNote(+ content: "Clipboard note",+ for: block,+ sourceIndex: 0,+ in: makeStructure(from: [block]),+ source: .clipboard,+ sessionID: session.id+ )+ let noteId = try #require(manager.documentNotes?.notes.first?.id)+ await manager.deleteNote(noteId)+ #expect(manager.documentNotes != nil)+ #expect(manager.documentNotes?.notes.isEmpty == true)++ // The destination already has notes of its own.+ await store.preload(DocumentNotes(+ identifier: resolver.resolve(from: firstURL),+ displayName: firstURL.lastPathComponent,+ notes: [BlockNote(+ blockId: block.id,+ contextQuote: "Pasted",+ content: "Destination's own note",+ status: .active,+ createdAt: Date(),+ modifiedAt: Date()+ )]+ ))++ let flow = ClipboardSaveFlow()+ session.prepareSave(to: firstURL)+ flow.start(session: session, notesManager: manager, onCompleted: { _, _ in }, onFailed: { _, _, _ in })+ await flow.drain()++ // Reachable with no race (T-1812 residual): `notesBelongToSaveChain`+ // alone would accept the empty container — its identifier still+ // matches the clipboard session — and migrate it, and+ // `migrateNotes`' atomic `store.save` would silently replace the+ // destination's own notes with nothing before deleting the clipboard+ // record. `hasNotes` has to stay part of the gate.+ #expect(session.source == .file(url: firstURL))+ #expect(manager.documentNotes?.identifier == resolver.resolve(from: firstURL))+ #expect(manager.documentNotes?.notes.map(\.content) == ["Destination's own note"])+ #expect(await store.storedNotes[resolver.resolve(from: firstURL).path]?.notes+ .map(\.content) == ["Destination's own note"]) } @Test("a load for another document during migration keeps that document's identity")
diff --git a/docs/agent-notes/notes-system.md b/docs/agent-notes/notes-system.mdindex bfb1aee0..ec58b879 100644--- a/docs/agent-notes/notes-system.md+++ b/docs/agent-notes/notes-system.md@@ -80,18 +80,21 @@ Notes work for all document source types. Clipboard identifiers use the format ` ### Migration on Save When a clipboard document is saved to a file, `DocumentReaderView.onChange(of: session.pendingSave)` hands the attempt to `ClipboardSaveFlow` (`prism/ViewModels/`), which:-1. If clipboard notes exist: `migrateNotes(fromClipboardSession:previousDestination:toFileURL:)` updates the identifier in-memory, saves to the file-based key, and deletes the old key-2. If no clipboard notes: loads file notes normally+1. If the currently loaded notes are ones this save chain actually **owns** *and* the container is non-empty — `notesManager.hasNotes && notesManager.notesBelongToSaveChain(sessionID:previousDestination:)` — `migrateNotes(fromClipboardSession:previousDestination:toFileURL:)` updates the identifier in-memory, saves to the file-based key, and deletes the old key+2. Otherwise: loads the destination file's own notes normally++ This is not "if clipboard notes exist / if no clipboard notes" — presence alone was the T-1812 residual (below), and ownership alone was a data-loss blocker caught in pre-push review of the residual fix. Both a foreign container (attempt-1's adopted destination notes, still loaded when attempt 2 runs) and an *owned but empty* one (create-then-delete leaves a non-nil empty container under the clipboard identifier, and `deleteNote`/`clearResolved` never nil it) must fall through to branch 2 — the first because it isn't this chain's to migrate, the second because migrating it would have `migrateNotes`' atomic `store.save` silently replace the destination's existing notes with nothing. `hasNotes` is `documentNotes?.notes.isEmpty == false`; `notesBelongToSaveChain` only tests identity. Neither alone is the gate; both are required. 3. On migration failure: the flow settles the session on whichever document actually holds the notes — `session.revertToClipboard()` only for the *first* attempt in a chain (see below); a later attempt calls `session.didSave(to:)` on the earlier attempt's file 4. On success: `session.didSave(to:)` plus `DocumentFlowCoordinator.completeSaveFlow` (recents entry) ### Consecutive Save As (T-1812) -The source deliberately stays `.clipboard` until migration succeeds (clipboard-notes Decision 6), which is also what keeps `isUnsaved` — and therefore the Save button — live for the whole migration. A second Save As can start inside that window, and the transition was designed as one-shot, so nothing distinguished the two attempts. Three things now do:+The source deliberately stays `.clipboard` until migration succeeds (clipboard-notes Decision 6), which is also what keeps `isUnsaved` — and therefore the Save button — live for the whole migration. A second Save As can start inside that window, and the transition was designed as one-shot, so nothing distinguished the two attempts. Four things now do: - **`DocumentSession.PendingSave`** carries a monotonic per-session `id` alongside the URL. `pendingSaveURL` is derived from it. The id, not the URL, is the identity: two attempts to the *same* file are still two attempts, and the view keys its `onChange` on `pendingSave` so the second one runs. - **`ClipboardSaveFlow` serialises attempts and gates finalisation.** A new attempt awaits the previous one rather than cancelling it — cancellation was never load-bearing here (nothing on this path checks `Task.isCancelled`, and `migrateNotes` has already moved the in-memory identity by its first suspension point), and serialising is what makes "where are the notes right now?" answerable. Every step after an `await` is gated on `session.isCurrentSaveAttempt`, so a superseded attempt cannot `didSave` to a destination the document has moved past or claim the newer attempt's bookmark. - **`previousDestination` travels with the migration.** `migrateNotes`' guard — "these notes must still carry the clipboard identifier" — is a precondition on the *first* attempt only. The second legitimately starts from the first's destination, and reading that as "not mine" is what stranded the notes on the first file. The flow owns the chain and passes the previous destination in; the clipboard identifier stays accepted too, in case the earlier attempt reverted. Deliberately *not* long-lived state inside `NotesManager`: remembering "this session's notes were moved to A" indefinitely would silently make exporting a copy of an already-saved document move its notes, which is a different behaviour change.+- **The migrate-vs-load gate is `hasNotes && notesBelongToSaveChain(sessionID:previousDestination:)`, not either alone.** `notesBelongToSaveChain` (`NotesManager.swift`) answers ownership: are the loaded notes the clipboard session's own, or notes an earlier attempt in this chain already migrated to `previousDestination`? Gating on `hasNotes` alone — the original fix's gate — was the residual T-1812 shape below: branch 2 loads a destination's own notes for display, so `hasNotes` goes true for the *next* attempt without those notes belonging to this chain. Gating on `notesBelongToSaveChain` alone is a silent data-loss bug caught in pre-push review of the residual fix: `deleteNote`/`clearResolved` can leave a non-nil, empty container under an owned identifier (create a note, delete it — nothing nils the container), and migrating an empty container onto an existing destination has `migrateNotes`' atomic `store.save` silently replace that destination's own notes with nothing. Both halves are required at the call site. Three rules govern `previousDestination`, and none of them is guessable from the field's name: @@ -106,11 +109,11 @@ Two consequences of settling that are easy to misread, both in Decision 7's Cons - **Settling genuinely ends the chain.** Save As is gated on `session.isUnsaved` (`RegularDocumentLayout`, `CompactBottomToolbar`, and the unsaved-confirmation dialog all check it), and the settled session is `.file`, so the Save button disappears — there is no in-app retry that moves the notes to the destination the user actually asked for. `failedMigrationMidChainSettlesOnTheNotesFile` drives a further `prepareSave` directly to pin what *would* happen; production cannot reach it through the UI. - **`handleSaveFailed` clears the persisted clipboard state on this branch.** `toPersistableState()` returns nil for a file source, so once the session settles, a background transition can never overwrite the clipboard entry an earlier one wrote. Left behind it restores a ghost copy of the pasted document on the next launch, under a clipboard identifier whose notes file this chain already deleted. `completeSaveFlow` does the same clear for the success path. -Two smaller guards inside `migrateNotes`: it re-checks `documentNotes?.identifier == targetIdentifier` after the store save before rebinding cached document identity (a load for another document can land while it is parked in the store — the T-1811 failure shape), and it skips the source delete when source and target are the same file (re-saving to the same destination would otherwise delete the notes it just wrote).+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). That post-save `targetIdentifier` guard is deliberately narrower than `notesBelongToSaveChain` above it: post-save, only the *target* identifier is acceptable, because substituting the broader chain predicate (which also accepts the clipboard/`previousDestination` identities) would rebind cached document identity onto a container that belongs to a different in-flight load — T-2231's symptom from the other side. Do not "simplify" the two guards to share the wider predicate. `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).+Regression coverage: `prismTests/ConsecutiveSaveAsTests.swift` — 12 tests, at least one per guard. The round-one tests were mutation-checked; the two reopening tests have not been (machine contention — see the bugfix report's Verification section), so treat their mutation matrix as intent, not evidence. ### Finalising After the Document Has Been Replaced (T-2213)
diff --git a/specs/bugfixes/consecutive-save-as-cross-wiring/report.md b/specs/bugfixes/consecutive-save-as-cross-wiring/report.mdindex bf1f90c8..e1bcc052 100644--- a/specs/bugfixes/consecutive-save-as-cross-wiring/report.md+++ b/specs/bugfixes/consecutive-save-as-cross-wiring/report.md@@ -1,9 +1,93 @@ # Bugfix Report: Consecutive Save As Cross-Wires Note Migration and Bookmarks -**Date:** 2026-08-15+**Date:** 2026-08-15 (original fix); reopened and re-fixed 2026-08-22 **Status:** Fixed **Ticket:** T-1812 +## Reopening (2026-08-22)++The original fix (below) gated the migrate-vs-load branch on `NotesManager.hasNotes`+(`documentNotes?.notes.isEmpty == false`). That is not the same question as "does+this chain own the loaded notes", and the gap reopened T-1812 in a new shape,+found by an independent pre-push review:++- **Residual (major, fixed here):** the no-notes branch *loads* a destination+ file's own pre-existing notes so they can be shown, which makes `hasNotes`+ true for the *next* attempt without those notes belonging to this chain. A+ second, superseded-before-finalising attempt inherits that foreign container;+ `hasNotes` cannot tell it apart from the chain's own notes, so the attempt+ takes the migrate branch, `migrateNotes`' own identity guard correctly+ refuses (the container matches neither the clipboard identifier nor a+ `previousDestination` this chain wrote), and — because a refusal reports+ success — the caller mistakes "nothing to do" for "done" and never loads its+ own destination's notes. The fix: add+ `notesManager.notesBelongToSaveChain(sessionID:previousDestination:)`+ (`NotesManager.swift`), a single ownership predicate shared by the caller+ gate (`ClipboardSaveFlow.run`) and `migrateNotes`' internal re-check, so the+ two can no longer disagree. The first commit *replaced* `hasNotes` with it,+ which is what the blocker below is about; the gate that landed is+ `hasNotes && notesBelongToSaveChain(…)`.+- **Blocker (found in review of the residual fix, fixed in the same commit):**+ `notesBelongToSaveChain` answers ownership only — `documentNotes != nil` and+ identifier match — and says nothing about whether the container has any+ notes in it. `hasNotes` had silently been doing double duty as both an+ ownership proxy *and* a non-emptiness check. Replacing it with the pure+ ownership predicate reopened the emptiness gap: `deleteNote`/`clearResolved`+ leave a non-nil, **empty** container under an owned identifier (create a+ note, delete it — nothing nils the container, and it persists and survives a+ reload). That container's identifier still matches, so+ `notesBelongToSaveChain` alone accepts it and the flow takes the migrate+ branch; `migrateNotes`' `store.save` writes atomically with no emptiness+ special-case, so **Save As over an existing document that already has notes+ silently replaces those notes with an empty container**, then deletes the+ clipboard record. Reachable with no race: paste, add a note, delete it, Save+ As onto an existing `.md` file that has notes. Fix: the call site in+ `ClipboardSaveFlow.run` now gates on+ `notesManager.hasNotes && notesManager.notesBelongToSaveChain(...)` — both+ halves are required; neither alone is safe in front of a store write.++**Why round one's tests did not catch the residual:** the round-one regression+tests asserted disk state only (which file ended up with which notes on+`store.storedNotes`), never the *in-memory* `NotesManager.documentNotes`+identity the session and the manager have to agree on. The residual left the+manager holding a foreign container while the session had already transitioned+— an in-memory drift invisible to a disk-only assertion. The new tests assert+both.++**New/extended tests** (`prismTests/ConsecutiveSaveAsTests.swift`):++- `loadedFileNotesAreNotTreatedAsThisChainsMigrationSource` — extended with two+ assertions (`session.source == .file(url: secondURL)`,+ `manager.documentNotes == nil`) pinning the residual: a chain with no notes+ of its own must end with no notes loaded, not a foreign container's.+- `supersededAttemptsPreexistingNotesDoNotLeakIntoTheNextDestination` (new) —+ both destinations have their own pre-existing notes; pins that the manager+ ends up on the *second* destination's own notes and content, and that+ neither file's stored notes were touched by a chain that never wrote them.+- `emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes` (new) — the+ blocker's exact empty-container shape: create a clipboard note, delete it+ (non-nil, empty, owned container), Save As onto a destination that already+ has its own notes. Pins that the manager ends up on the destination's+ identity *and* its original content, and that the destination's stored notes+ were not touched. The mutation it is meant to catch is reverting the+ call-site gate to `notesBelongToSaveChain` alone (dropping `hasNotes &&`),+ which makes the content/identity assertions fail. That mutation was built+ but **never run to a verdict** on this machine — see Verification. Note the+ matrix is asymmetric by design: this test does *not* catch dropping+ `notesBelongToSaveChain`, because with an empty container `hasNotes` is+ already false and the load branch is taken either way. The other half is+ pinned by `supersededAttemptsPreexistingNotesDoNotLeakIntoTheNextDestination`.++T-2231 (open, unrelated) concerns the *post-save* identity guard+(`NotesManager.swift`, `guard documentNotes?.identifier == targetIdentifier+else { return true }`), not the pre-save gate this reopening touches. That+guard is deliberately **narrower** than `notesBelongToSaveChain`: post-save,+only the target identifier is acceptable, because substituting the broader+chain predicate there would accept the clipboard/`previousDestination`+identities as "still mine" and rebind cached document identity onto a+container that belongs to a different in-flight load — T-2231's symptom from+the other side. This reopening neither fixes nor worsens T-2231.+ ## Description of the Issue A pasted (clipboard) document stays *unsaved* while its notes migrate: the@@ -163,11 +247,25 @@ later attempt can rely on, and no amount of generation-checking recovers it. | `failedFirstAttemptStillRevertsToTheClipboard` | the single-attempt failure still keeps the source as clipboard and reports no file (Decision 6 / Req 4.5 unchanged for the first attempt) | | `failedMigrationMidChainKeepsTheNotesReachable` | a superseded failing attempt hands on its own source, so the next attempt still finds and moves the notes | | `failedMigrationMidChainSettlesOnTheNotesFile` | a current failing attempt mid-chain settles the session on the file holding the notes and reports that file, instead of claiming clipboard storage that no longer exists |-| `loadedFileNotesAreNotTreatedAsThisChainsMigrationSource` | an attempt that only loaded a file's own notes does not let the next one migrate — and delete — a pre-existing strand |+| `loadedFileNotesAreNotTreatedAsThisChainsMigrationSource` | an attempt that only loaded a file's own notes does not let the next one migrate — and delete — a pre-existing strand; extended (reopening) to also assert the manager ends with no notes, not a foreign container's |+| `supersededAttemptsPreexistingNotesDoNotLeakIntoTheNextDestination` (reopening) | two destinations with their own pre-existing notes: the manager ends on the *second* destination's own identity and content, and neither file's stored notes are touched |+| `emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes` (reopening) | the blocker shape: an owned but empty clipboard container (create-then-delete) does not migrate onto — and wipe — a destination's existing notes | | `migrationDoesNotRebindIdentityOfADocumentLoadedMidFlight` | a migration parked in the store cannot rebind cached identity onto a document loaded while it was suspended | | `recentEntryBookmarkMatchesItsURL` | a recent entry resolves to the file it is labelled with | -Each was checked by mutation: removing the current-attempt guard fails the three+The two reopening tests are *intended* to mutation-check one half of the gate+each — but neither check completed on this machine, so this is the design of+the matrix, not evidence from a run (see Verification). Reverting the+`ClipboardSaveFlow.run` gate to `notesBelongToSaveChain` alone (dropping+`hasNotes &&`) should fail+`emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes` on the+identity/content assertions; reverting it to `hasNotes` alone (the original,+round-one gate) should fail `supersededAttemptsPreexistingNotesDoNotLeakIntoTheNextDestination`+and the extended assertions on `loadedFileNotesAreNotTreatedAsThisChainsMigrationSource`.+Each test catches exactly one half — neither catches both — so both tests have+to stay for the gate to be pinned.++Each of the round-one tests was checked by mutation: removing the current-attempt guard fails the three session tests, removing the `previousDestination` acceptance fails the notes test, resetting `previousDestination` to `nil` on failure fails the mid-chain reachability test, reverting to the clipboard on a mid-chain failure fails the@@ -194,17 +292,24 @@ xcodebuild test -project prism.xcodeproj -scheme prism \ | `prism/Models/DocumentSession.swift` | `PendingSave` attempt identity; `isCurrentSaveAttempt` | | `prism/ViewModels/ClipboardSaveFlow.swift` | New: serialised, attempt-gated save flow | | `prism/Views/DocumentReaderView.swift` | Delegates the save flow; observes `pendingSave` |-| `prism/Services/NotesManager.swift` | `previousDestination`, post-await identity guard, same-file delete guard |+| `prism/Services/NotesManager.swift` | `previousDestination`, post-await identity guard, same-file delete guard. **Reopening:** added `notesBelongToSaveChain(sessionID:previousDestination:)`, the shared ownership predicate used by both the caller gate and `migrateNotes`' internal re-check; one-line notes on the post-save identity guard explaining it stays narrower than the new predicate (T-2231) | | `prism/ViewModels/DocumentFlowCoordinator.swift` | URL-keyed pending bookmark; clears persisted clipboard state when a failure settles on a file |-| `prismTests/ConsecutiveSaveAsTests.swift` | New: regression suite |+| `prismTests/ConsecutiveSaveAsTests.swift` | New: regression suite. **Reopening:** one test extended, two tests added (see Regression Test) | | `specs/clipboard-notes/decision_log.md`, `requirements.md` | Decision 7 (supersedes Decision 6 in part); Req 4.5 amended | | `CHANGELOG.md`, `docs/agent-notes/notes-system.md` | Documentation | +**Reopening:** `prism/ViewModels/ClipboardSaveFlow.swift` — the migrate-vs-load+gate at `run` moved from `notesManager.hasNotes` to+`notesManager.hasNotes && notesManager.notesBelongToSaveChain(sessionID:previousDestination:)`+(first to the ownership predicate alone, which reopened the bug in a new+shape; then, per pre-push review of that change, back to requiring both).+ ## Verification **Automated:** -- [x] Regression tests pass (10 tests, both locale runs)+- [x] Regression tests pass (10 tests at the time, both locale runs; 12 after+ the reopening — the two new ones are covered in the reopening block below) - [x] Targeted sweep passes: all `NotesManager*` clipboard/load/sign-in suites, `SaveFlowIntegrationTests`, `DocumentSessionTests`, `ConfirmationDialogIntegrationTests`, `DocumentFlowCoordinatorRecentFileTests`@@ -217,6 +322,34 @@ xcodebuild test -project prism.xcodeproj -scheme prism \ the migration window, which the tests reproduce deterministically and a human cannot reliably hit. +**Reopening verification (2026-08-22):**++- [x] `make build-macos` — clean+- [x] `make lint` — 0 violations+- [x] `make verify-test-isolation` — the two new/extended tests are `async` in+ the existing `@MainActor .serialized` suite+- [ ] Mutation check on `emptiedClipboardContainerDoesNotOverwriteDestinationsOwnNotes`:+ the mutated build (gate reverted to `notesBelongToSaveChain` alone,+ dropping `hasNotes &&`) compiled cleanly under `build-for-testing`, but+ the locked test run to confirm it goes red never reached a verdict — it+ sat queued behind other agents' `xcodebuild` runs on this shared machine+ for over 30 minutes and was cancelled rather than retried indefinitely.+ The fix was restored immediately after (confirmed by re-diffing against+ the pre-mutation source) and rebuilt clean. Not run-verified; the failure+ mode is inferred directly from the code path (`migrateNotes`'+ `store.save` is unconditional and atomic once the gate is passed) and+ matches what the independent pre-push review already confirmed+ statically for this exact predicate difference.+- [ ] Full targeted run of `ConsecutiveSaveAsTests` + `NotesManagerClipboardMigrationTests`+ (bundle `T-1812-4.xcresult`, one locked attempt as instructed): the test+ host failed to launch — `Could not launch "prismTests" ... prism.app+ couldn't be opened because there is no such file` — with zero tests+ executed (`Tools/check-test-results.sh`: `total=1 passed=0 failed=1`,+ "1 never ran ... the host crashed mid-run"). This is the documented+ machine-contention shape (several agents' `xcodebuild` runs sharing this+ machine), not a test failure, and per the operating rules for this run+ was recorded as unverified rather than retried.+ ## Prevention - Asynchronous work started from view state needs an identity when the state it
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..17a27d85 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -38,8 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Share with Notes on a remote document opened from a host-only or trailing-slash root URL (e.g. `https://example.com`) no longer always fails (T-1822). The display name used for that document falls back to its full absolute URL so a root URL never shows a blank title (T-1177) — but Share with Notes reused that same string, unsanitised, as the temporary export file's name. The URL's `/` and `:` characters were read as path separators, so the write always targeted a nonexistent nested directory and "Export Failed" appeared every time. The export filename is now derived from a sanitised version of the display name, with path separators and control characters stripped rather than passed through. - A block whose text is exactly a thematic break — `---`, `***`, or `___` — no longer renders empty (T-1669). Rendering re-parses a block's text as its own small markdown document, and that string satisfying markdown's rule for a horizontal rule made it parse as one rather than as plain text; nothing then knew how to turn a rule back into visible text, so it vanished. This is the third defect of this shape (after numbered list markers and `@`-prefixed text, T-1640/T-1641): rendering now knows a rule found this way as text too, and shows the characters as written — in a paragraph, a heading, a list item, or a table cell alike.-- Saving a pasted document twice in quick succession no longer leaves its notes behind on the first file (T-1812). A pasted document counts as unsaved until its notes have followed it to the file you saved it as, and the Save button stays available for that moment — so choosing **Save** again straight away started a second save while the first was still moving the notes. The second one found the notes already carrying the first file's name, decided they were not its to move, and quietly left them there: the document became the second file while its notes stayed with the first, where nothing showed them and deleting that file would have taken them with it. The first save could also finish afterwards and hand the document back to the file you had just saved past, and the entry it added to Recent Files could open the other file entirely. Consecutive saves now run one after another and only the last one counts: the notes follow the document to wherever you finally saved it, the earlier attempt can no longer take the document back, and a Recent Files entry always opens the file it names. Saving twice to the same file keeps the notes there rather than removing them, and a save whose notes could not be moved still reports that and leaves the document pasted, as before.-- Saving a pasted document twice in quick succession no longer leaves its notes behind on the first file (T-1812). A pasted document counts as unsaved until its notes have followed it to the file you saved it as, and the Save button stays available for that moment — so choosing **Save** again straight away started a second save while the first was still moving the notes. The second one found the notes already carrying the first file's name, decided they were not its to move, and quietly left them there: the document became the second file while its notes stayed with the first, where nothing showed them and deleting that file would have taken them with it. The first save could also finish afterwards and hand the document back to the file you had just saved past, and the entry it added to Recent Files could open the other file entirely. Consecutive saves now run one after another and only the last one counts: the notes follow the document to wherever you finally saved it, the earlier attempt can no longer take the document back, and a Recent Files entry always opens the file it names. Saving twice to the same file keeps the notes there rather than removing them. A save whose notes could not be moved still tells you so, and now tells you where they are: the first such save leaves the document pasted as before, but a later one hands the document back to the file that already holds its notes and names that file, instead of pointing you at clipboard storage the earlier save had already cleared out.+- Saving a pasted document twice in quick succession no longer leaves its notes behind on the first file (T-1812). A pasted document counts as unsaved until its notes have followed it to the file you saved it as, and the Save button stays available for that moment — so choosing **Save** again straight away started a second save while the first was still moving the notes. The second one found the notes already carrying the first file's name, decided they were not its to move, and quietly left them there: the document became the second file while its notes stayed with the first, where nothing showed them and deleting that file would have taken them with it. The first save could also finish afterwards and hand the document back to the file you had just saved past, and the entry it added to Recent Files could open the other file entirely. Consecutive saves now run one after another and only the last one counts: the notes follow the document to wherever you finally saved it, the earlier attempt can no longer take the document back, and a Recent Files entry always opens the file it names. Saving twice to the same file keeps the notes there rather than removing them. A save whose notes could not be moved still tells you so, and now tells you where they are: the first such save leaves the document pasted as before, but a later one hands the document back to the file that already holds its notes and names that file, instead of pointing you at clipboard storage the earlier save had already cleared out. A second Save As that had no notes of its own to move could also end up believing it had moved some: the earlier save had loaded its own destination's existing notes just to show them, and the second save mistook those for the notes it was meant to carry, leaving the document showing an unrelated file's notes. That save now shows its own destination's notes instead. - Pasting ordinary prose into clipboard mode no longer sometimes rewrites it as a broken mermaid diagram (T-1840). Prism auto-wraps clipboard text that looks like raw mermaid diagram source in a codefence so it renders correctly; the check for "looks like mermaid source" was loose enough that a paragraph with a numbered citation (`results[3]`), a parenthetical aside written without a leading space (`the tool(a favorite)`), or a sentence that happened to start with the word "section" or "title" could satisfy it, as long as the paragraph's first word was also a mermaid keyword — "Graph", "Pie", "Journey", "Timeline", "Kanban", "Architecture", and "Block" are all both. Genuine mermaid statement and label lines are terse and don't end in sentence punctuation; the check now skips any line that does, which rules out this class of prose false positive without weakening detection of any real diagram shape, including the ones (pie, gantt, journey, timeline) that have no arrow operator to fall back on. - Pasting ordinary prose into clipboard mode no longer sometimes rewrites it as a broken mermaid diagram (T-1840). Prism auto-wraps clipboard text that looks like raw mermaid diagram source in a codefence so it renders correctly; the check for "looks like mermaid source" was loose enough that a paragraph with a numbered citation (`results[3]`), a parenthetical aside written without a leading space (`the tool(a favorite)`), or a sentence that happened to start with the word "section" or "title" could satisfy it, as long as the paragraph's first word was also a mermaid keyword — "Graph", "Pie", "Journey", "Timeline", "Kanban", "Architecture", and "Block" are all both. The check now recognises two tiers of evidence: operator syntax that prose never produces — arrows, relationship operators, pie's `"label": number` lines — counts on any line, while the ambiguous shapes prose can also produce (bracketed or parenthesised tokens, lines starting with "section" or "title") only count on lines that don't read as declarative sentences, i.e. don't end in a period. That rules out this class of prose false positive without weakening detection of any real diagram shape: a sequence diagram whose messages end in `?` or `!` (`Alice->>Bob: Are you there?`), a gantt or journey whose title asks a question, and the diagram types with no arrow operator to fall back on (pie, gantt, journey, timeline) all still wrap. - Pasting ordinary prose into clipboard mode no longer sometimes rewrites it as a broken mermaid diagram (T-1840). Prism auto-wraps clipboard text that looks like raw mermaid diagram source in a codefence so it renders correctly; the check for "looks like mermaid source" was loose enough that a paragraph with a numbered citation (`results[3]`), a parenthetical aside written without a leading space (`the tool(a favorite)`), or a sentence that happened to start with the word "section" or "title" could satisfy it, as long as the paragraph's first word was also a mermaid keyword — "Graph", "Pie", "Journey", "Timeline", "Kanban", "Architecture", and "Block" are all both. The check now weighs each line by how much the syntax on it could plausibly be prose. Operator syntax prose never produces — arrows, class and ER relationship operators, pie's `"label": number` lines — counts wherever it appears, even on a line ending in sentence punctuation, because a sequence-diagram message routinely does (`Alice->>Bob: Are you there?`). The ambiguous shapes prose also produces — bracketed or parenthesised tokens, edge-label pipes, and lines starting with "section", "title", or "dateFormat" — are ignored on any line that ends in `.`, `?`, or `!`, which is what a sentence does and a diagram label usually doesn't. Genuine diagram titles and sections *can* end in `?` or `!` (`title Are we on track?`), so those keywords get one narrow exemption: they still count when the text opens with an actual diagram declaration — the type keyword on its own, as in `gantt` or `journey`, not merely a paragraph whose first word happens to be one — and that declared type is one the keyword belongs to (`title` and `section` in gantt, journey, timeline, and pie; `dateFormat` in gantt alone). So a real gantt or journey whose title asks a question still wraps, while a paragraph opening "Timeline mapping is useful" and later carrying "section 3 covers this?" does not. Detection of every real diagram shape is unchanged, including the types with no arrow operator to fall back on (pie, gantt, journey, timeline).
This is the only must-do. xcodebuild test -only-testing:prismTests/ConsecutiveSaveAsTests to a real verdict, and check the result bundle reports a non-zero executed count — the two failures on record for this branch are a cancelled run and a host-launch failure with zero tests executed, both of which look like nothing rather than like red.
The conjunction routes 'hasNotes true, not ours' to loadNotes, which previously took the (inert) migrate branch. loadNotes is not gated on attempt currency. I traced this and it converges: the reader keys on the session id via .navigationDestination(for: UUID.self) and holds notesManager and saveFlow as sibling @State, so a flow outliving its reader writes into a manager no view holds; within one session loadGeneration plus the parseRevision reload settle on the final destination. Recorded because the reasoning is not local to the diff and the next reader of run will not have it.
origin/main is at a4a8edd6; the merge base is f2f39672. The two commits touch Tools/check-test-results.sh, Tools/check-webkit-test-isolation.py and docs/agent-notes/development-tooling.md — no overlap with this branch's files, so no conflict. Worth rebasing before the push so the test run happens against current tooling.
Both deleteNote (NotesManager.swift:970-987) and clearResolved (:1019-1040) assign the mutated container back to documentNotes and call persistNotes, with no nil-when-empty path. The claim that the empty-container shape is reachable with no race — paste, add a note, delete it — is accurate, and it survives a reload.