A data-loss fix for NotesManager.loadNotes: a note created while the notes store read was suspended was overwritten by the load's pre-await snapshot, and deleted from iCloud when the load also relocated. Fourth review pass over PR #373 — the guard itself was confirmed correct in rounds 1–3, so this pass targeted claim accuracy and local validation.
loadNotes captures documentNotes as baselineNotes immediately before its store await; applicableNotes then applies the in-memory container instead of the loaded snapshot when it is for the same document and no longer equals that baseline.cachedDocumentPath/T-369, loadGeneration/T-1556). A mutation bumps no generation and moves no path, so both passed. T-1811 had already hardened the other direction inside noteContainer.iCloudAvailable, never on isLoading — the document is fully interactive while its notes load.NotesManager.swift:1096 and line 1120; round 2's own +16-line edit to that same file had already shifted them to 1112/1136. The citation was stale in the commit that introduced it. Now replaced with code anchors that cannot drift.migrateNotes' save-failure revert publishes under the source identifier and never persists — it answers neither half of the question the comment tells readers to apply. It is safe for a third reason (baseline equality), which was unstated. Now written down.isLoading vacuity guard works, and its 200ms/50ms timing is the file's existing convention, not a new flake vector.signal abrt cascade record from the known T-2219 crasher, with zero assertion failures. Verified by re-running the notes suites in isolation: 69/69.Ready to push
The production guard is correct and unchanged by this pass. applicableNotes(loaded:baseline:identifier:) is nine lines of logic, is the exact mirror of noteContainer(for:)'s post-await re-read, and every writer of documentNotes was re-verified against it — including the one path the doc comment had missed. Local macOS validation is green: both platform builds succeed, SwiftLint reports 0 violations in 541 files, and the eight notes suites pass 69/69 with counts read from the result bundle.
All five findings were documentation accuracy, not defects — and they matter here precisely because two false claims had already been corrected in earlier rounds. The headline one is a third: the agent note cited two line numbers that the same commit that wrote them had already invalidated. They are fixed and committed in 4c329f5; nothing is left uncommitted.
CI is not a gate on this repo — GitHub Actions is billing-blocked and Linux-only, so it cannot build or run a single test in this PR. The local macOS run above is the only real verification this change will ever receive before merge.
480408b T-2089: Add failing regression tests for load-versus-mutation race 70fc982 Fix T-2089: Initial note load can overwrite notes created during loading d764f7c T-2089 review round 1: correct a false claim and complete the safety argument c755edc T-2089 review round 2: state the justification that actually applies 4c329f5 T-2089 pre-push review: retire the stale citations and the last count Prism stores your notes in iCloud. When you open a document, it asks iCloud for that document's notes — and because that takes a moment, the app lets you read and write notes while it waits.
That created a trap. When iCloud finally answered, the app would take the list of notes it had asked for and put it on screen, wholesale. If you had written a note during the wait, that note was not in the list iCloud sent back — because it did not exist when the question was asked. So your note vanished the instant the load finished.
Worse: sometimes a load also has to re-attach notes to text that has moved since you last opened the file, and when it does, it saves the result back to iCloud. So the older list — the one without your note — got written to iCloud too. The note was not just off-screen until you reopened the document. It was gone.
Silently losing something a user typed is the worst thing a note-taking feature can do. There is no error, no warning, and no way to get the note back. And the trigger is completely ordinary: open a document, start typing a note straight away, on a slow or cold iCloud connection.
Before it asks iCloud anything, the load now takes a snapshot of what the notes look like right at that moment. When iCloud answers, it compares: are the notes still exactly as I left them? If yes, apply what iCloud sent. If they have changed — someone wrote or deleted a note while I was waiting — then what is on screen is newer than what I am holding, so keep it.
NotesManager is a @MainActor @Observable class holding documentNotes: DocumentNotes? as the in-memory source of truth, backed by NotesStore (an actor) for persistence. loadNotes(identifier:blocks:) reads the store, relocates block notes against the current document blocks, and publishes to documentNotes/anchoredNotes.
The function already carried two race guards, and the shape of the bug is that both are the same kind of guard:
cachedDocumentPath (T-369) — separates different documents.loadGeneration (T-1556/T-1586) — orders reloads of one document.Both order load against load. A mutation bumps no generation and moves no cached path, so it is invisible to both — the guards pass and the pre-await snapshot is published over a note created during the await.
The fix is capture-and-compare:
let baselineNotes = documentNotes // before the await
let loadedNotes = await store.load(for: identifier)
// ... existing generation + path guards ...
guard let notes = applicableNotes(
loaded: loadedNotes, baseline: baselineNotes, identifier: identifier
) else { clearNoteState(); return }And the decision itself is four lines:
guard let current = documentNotes,
current.identifier == identifier,
current != baseline else { return loaded }
return currentThis is a deliberate mirror of noteContainer(for:), which T-1811 hardened by re-reading documentNotes after its own store await. Between the two, whichever of load and mutation resumes last defers to what the other published — the window is closed from both ends rather than patched on one side.
isLoading would make a correctness property depend on UI state and would block typing during exactly the slow loads where users are most likely to be waiting.DocumentNotes is synthesised Equatable, so the compare is only reliable because every mutator stamps notes.modifiedAt before publishing. That invariant is now recorded on the documentNotes declaration where a future mutator author will see it. The failure direction is asymmetric and favourable: a container that differs when it need not simply makes memory win, which is already the safe outcome.
The safety argument is that memory is derived from the store rather than a divergent branch of it. Round 2 wisely replaced an enumerated roster (whose count had been stated wrongly three times: ten, a dozen-odd, eight — actual is 14 assignment sites) with a question a reader applies to any writer: does what it publishes reach the store, or does it carry an identifier this load does not own?
Verified against every writer:
createAndPersistNote, createReply, handleDocumentNoteCreation) — baseline from noteContainer(for:), which reads the store itself; each ends in persistNotes. Container equals what this load read plus mutations already on disk.updateNote, deleteNote, toggleStatus, clearResolved, reattachNote) — never read the store; all five confirmed to end in await persistNotes(notes). Memory is the store's near future; the load's snapshot is its past.migrateNotes — republishes under a different identifier before its save, so the identifier check declines it and the load correctly applies its own result (T-1811 behaviour).The comment states that a writer answering neither half "would reopen this bug and needs its own argument here" — and migrateNotes' save-failure revert is precisely such a writer. It republishes under the source identifier and never persists. It is benign for a third reason the comment did not give: the revert restores the container to its pre-migration value, so an in-flight source-document load finds current == baseline, the guard declines, and that load applies its own result. This pass writes that down. Four further writers the "three ways" framing silently omitted (loadNotes itself, clearNoteState, noteContainer's publish of stored, saveCurrentState) are likewise now named with why none needs an argument.
The guard's converse — memory unchanged implies the loaded snapshot is at least as fresh — is the fragile half. It holds only because NotesStore.load and save are async yet contain no internal await, so actor isolation serialises them and a save enqueued before a load is always visible to that load. Add a suspension point and the bug reopens in a shifted window: a mutation that publishes before the load captures its baseline but whose write lands after the load's read leaves current == baseline, the guard does not fire, and the stale snapshot wins.
This is not hypothetical. T-1723/T-1895 (multi-window notes) proposes NSFileCoordinator coordination in exactly those methods. Round 2 correctly relocated the caution from a decode-failure catch inside private loadFromCurrentPath — which nobody wrapping save would open — onto load and save themselves as - Important: pointers. That is the single highest-value line in the diff.
The infrastructure fix is the sharpest thing in the PR. DelayedNotesStore.load read storedNotes after its artificial delay, modelling a load that observes writes made while it was suspended — which laundered the mid-load creation into the "loaded" result. All three new tests passed against unfixed code until it was corrected to snapshot before sleeping. Worth noting: that mock could have masked other load-versus-write races in this file for as long as it has existed.
The two guard-halves are pinned by different tests and are easy to conflate — round 1's commit message conflated them, and round 2 corrected it. noteDeletedDuringReloadIsNotResurrected pins the deferral; only reloadWithoutMutationAppliesStoreSnapshot pins the current != baseline clause (dropping it makes memory win unconditionally, which the deletion test asserts anyway).
backupIfNeeded now receives the in-memory container rather than the loaded snapshot. Still pre-relocation, which is what Req 5.7 needs — and strictly better, since the backup now includes the user's new note. The surrounding comment said "taken from the loaded notes" and is corrected here.loadNotes' prologue bumps loadGeneration with no await before it (verified).cachedDocumentPath write passes both guards. Identical to pre-PR behaviour — a pre-existing window this guard neither opens nor closes.anchoredNotes across the backupIfNeeded await) is correctly scoped out to the T-1586 backup await and filed rather than silently patched.NotesManager.swift
Why it matters. This is the entire behavioural change and the whole data-loss fix. Nine lines of logic guarding against silent, unrecoverable loss of user-authored notes. Everything downstream — partition, relocation, backup, relocation save — then runs on the fresher container, so the note is relocated and persisted rather than discarded.
What to look at. NotesManager.swift — `baselineNotes` capture at the store await; `applicableNotes(loaded:baseline:identifier:)`
NotesStore.swift
Why it matters. The guard's converse premise — memory unchanged implies the loaded snapshot is at least as fresh — holds ONLY because `load` and `save` are `async` with no internal `await`. T-1723/T-1895 proposes adding `NSFileCoordinator` to exactly these methods, which would reopen the bug in a shifted window where `current == baseline` and the guard cannot fire.
What to look at. NotesStore.swift — `- Important:` pointers on `load` and on `save`
NotesManagerLoadRaceTests.swift
Why it matters. The mock read `storedNotes` AFTER its artificial delay, modelling a load that observes writes made while it was suspended. That laundered the mid-load creation into the 'loaded' result and hid the race completely — all three new tests passed against unfixed code until this was corrected.
What to look at. NotesManagerLoadRaceTests.swift — `DelayedNotesStore.load`, snapshot taken before the `Task.sleep`
notes-system.md
Why it matters. The agent note cited `NotesManager.swift:1096` and `line 1120` for the `migrateNotes` rebind and the `cachedDocumentPath` write. Those were correct at commit d764f7c — and round 2's OWN +16-line edit to that file shifted them to 1112/1136 in the very commit that wrote the citation. This is the third false claim on a PR that had already corrected two.
What to look at. notes-system.md — migrate-window paragraph, now describing code anchors instead of line numbers
NotesManager.swift
Why it matters. The comment tells readers a writer answering neither half of its question 'would reopen this bug and needs its own argument here' — and `migrateNotes`' save-failure revert is exactly that writer: it republishes under the SOURCE identifier and never persists. It is benign, but for a third reason that was unstated.
What to look at. NotesManager.swift — `applicableNotes` doc comment, migrate bullet and the writers-not-listed parenthetical
A union keyed by note id is wrong, not merely redundant: a delete that lands after the load's read leaves the note present in the snapshot and absent from memory, so merging resurrects a note the user just removed. There is no per-note version information available to arbitrate with.
The ticket suggested a revision counter. It would need hand-bumping at 14 documentNotes assignment sites and reopens this exact bug silently the first time one is forgotten. Round 1 established that a didSet could automate it — the earlier claim that @Observable drops properties carrying accessors was false and was verified false by compiling a probe (only genuinely computed get/set properties drop out, which is why AppSettings re-adds access/withMutation by hand for those). The counter stays rejected on a ground that is true: automated or not, it carries no information the value comparison does not already carry.
Serialising mutations behind the load, or disabling note submission while isLoading, would make a correctness property depend on UI state and would block typing for the duration of exactly the slow loads users are most likely to be sitting through. The ticket scopes UI disabling as defence in depth, not as the mechanism.
A container for another document is not fresher truth about this one — it is the T-1811 hazard, and applying this load's own result is the correct behaviour there. It also cannot be defeated by a mid-load document switch: a switch runs loadNotes, whose prologue bumps loadGeneration before any await, so the generation guard retires that case well before applicableNotes runs.
A second, pre-existing window exists between documentNotes = notes and saveCurrentState(), where a relocation lives only in anchoredNotes and any writer calling rebuildAnchoredNotes() during backupIfNeeded rebuilds from pre-relocation block IDs. Consequence is a dropped relocation write, not note loss. It belongs to the T-1586 backup await rather than to this guard — correctly scoped out and ticketed rather than patched by a silent test tweak.
Applied during this review pass. The two cited line numbers had been invalidated by the same commit that wrote them; describing the surrounding statements instead removes the whole class of drift. A parenthetical records why they are not to be reintroduced.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | docs/agent-notes/notes-system.md — migrate-window paragraph | Cited `NotesManager.swift:1096` (container rebind) and `line 1120` (cachedDocumentPath write). Both were stale on this branch — the actual lines are 1112 and 1136. They were correct at commit d764f7c and were shifted by round 2's own +16-line edit to the same file, i.e. the citation was wrong in the commit that introduced it. The same numbers appear in the PR body. This is the third false claim on a PR that had already corrected two. | Replaced with code anchors (`documentNotes = notes` following `notes.identifier = targetIdentifier`, versus `cachedDocumentPath = targetIdentifier.path` past the T-1812 re-check), plus a parenthetical recording why line numbers must not be reintroduced. Committed in 4c329f5. NOTE: the PR body still carries 1096/1120 and needs the same correction — see 'Double-check'. |
| major | NotesManager.swift `applicableNotes` doc comment — roster completeness | The comment states that a writer answering neither half of its question 'would reopen this bug and needs its own argument here', then asserts 'today's writers answer it three ways'. That is not exhaustive: `migrateNotes`' save-failure revert republishes under the SOURCE identifier and never persists, answering neither half. It is benign — the revert restores the container to its pre-migration value, so an in-flight source-document load finds `current == baseline` and applies its own result — but that argument was unstated. Four further writers were silently omitted (`loadNotes` itself, `clearNoteState`, `noteContainer`'s publish of `stored`, `saveCurrentState`); all are safe. | Added the revert path as an explicit case with its baseline-equality argument, and named the four omitted writers with why none needs one. The reframing from roster to question is genuinely better and is kept; the sentence that reasserted exhaustiveness is what was fixed. Committed in 4c329f5. |
| minor | NotesManager.swift + notes-system.md — surviving writer count | Round 2's commit message states it stopped counting writers because the count had been written down wrong three times ('ten', 'a dozen-odd', 'eight'). But 'assigned from a dozen-odd places' survived in BOTH the code comment and the agent note (and the PR body) — and 'a dozen-odd' is one of the three counts that same message names as wrong. The true figure is 14 assignment sites. Separately, the comment said the count had been wrong 'twice already' while the commit message says three times. | Both occurrences changed to 'many places', so there is no number to maintain; 'wrong count twice already' became 'more than once'. Committed in 4c329f5. |
| minor | NotesManager.swift:396 + notes-system.md — a false 'only' | Both stated that '`loadNotes` clears the container only on its `guard iCloudAvailable` branch'. Literally false: `loadNotes` also calls `clearNoteState()` on the nil-applicable branch roughly 20 lines below. The argument is unaffected — what it needs is 'before its store await' — but a reader checking 'only' against the source finds a second call in the same function, which is the same failure mode the earlier rounds set out to eliminate. | Both now read 'clears the container *before its store await* only on its `guard iCloudAvailable` branch (its other `clearNoteState()` runs after that await)'. Committed in 4c329f5. |
| minor | NotesManager.swift relocation-backup comment | The pre-existing comment says the backup 'is taken from the loaded `notes` (pre-relocation state)'. After this change `notes` is the container `applicableNotes` resolved to apply, which may be the in-memory one rather than the loaded snapshot. Semantics still hold (it is pre-relocation either way, and now strictly better since the backup includes the user's new note), but the wording was made stale by this PR and nobody had flagged it. | Reworded to name `notes` as the container this load resolved to apply, noting it may be the in-memory one since T-2089 and is pre-relocation either way. Committed in 4c329f5. |
| nit | NotesManagerLoadRaceTests — `isLoading` vacuity guard scope | The `#expect(manager.isLoading)` guard is exact in this test only because the stored note already anchors to `block`, so the reload does not relocate and there is no await between publishing its snapshot and returning. If a future edit gave the fixture a relocating note, `isLoading` would remain true across the `backupIfNeeded` tail and the guard would weaken from 'still parked in store.load' to merely 'has not returned yet'. | Recorded as a comment beside the guard so the constraint travels with the fixture. The guard itself is correct as written and was not changed. |
| nit | PR body — test count | The Tests section says '`prismTests/NotesManagerLoadRaceTests.swift`, four tests:' and then lists five. | Not fixed — the PR body is not a repo file. Flagged for the author to correct alongside the stale line numbers. |
| nit | Documentation duplication across three surfaces | The same multi-paragraph safety justification is maintained verbatim in the code comment, the agent note, AND the PR body. Two rounds of corrections had to be applied to all three, and the stale line numbers survived in two of them precisely because of that fan-out. This is structural: the duplication is what makes each correction a three-place edit that can be partially applied. | Not restructured — the argument genuinely belongs next to the guard, and consolidating it now would churn a data-loss fix for stylistic gain. Worth considering separately: let the agent note be canonical and have the code comment point at it. |
Click to expand.
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex 3c081e5..a4d664c 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -23,6 +23,17 @@ final class NotesManager { // MARK: - Published State /// The loaded notes for the current document.+ ///+ /// **Invariant: bump `notes.modifiedAt` before publishing a mutated+ /// container.** Every mutator does, and `applicableNotes` (T-2089) rests on+ /// it: that guard spots a mutation that raced a load by comparing this+ /// container against the one the load captured before its store await, using+ /// `DocumentNotes`' synthesised `Equatable`. A mutator that publishes without+ /// the bump, and whose other fields happen to land back where they started,+ /// compares equal — the guard misses the mutation and the load's stale+ /// snapshot wins, which is the bug T-2089 fixed. The opposite error is+ /// harmless: a container that differs when it need not makes memory win,+ /// already the safe direction. private(set) var documentNotes: DocumentNotes? /// Whether notes are currently being loaded.@@ -284,6 +295,9 @@ final class NotesManager { isLoading = true defer { isLoading = false } + // The container as this load found it, for the mutation check below.+ let baselineNotes = documentNotes+ // Load from iCloud (async, non-blocking per 12.3) let loadedNotes = await store.load(for: identifier) @@ -294,7 +308,13 @@ final class NotesManager { guard generation == loadGeneration else { return } guard cachedDocumentPath == identifier.path else { return } - guard let notes = loadedNotes else { clearNoteState(); return }+ // Neither guard above orders this load against a *mutation*: creating a+ // note bumps no generation and moves no cached path, so both pass and+ // the pre-await snapshot gets published over a note that did not exist+ // when it was read (T-2089).+ guard let notes = applicableNotes(+ loaded: loadedNotes, baseline: baselineNotes, identifier: identifier+ ) else { clearNoteState(); return } // Partition notes into document-level and block notes let documentLevelNotes = notes.notes.filter { $0.isDocumentLevel }@@ -322,7 +342,9 @@ final class NotesManager { if notesWereRelocated { // Back up the pre-relocation notes store before the new renderer's // first relocation write (webview-rendering Req 5.7). The backup is- // taken from the loaded `notes` (pre-relocation state); the store+ // taken from `notes` — the container this load resolved to apply,+ // which since T-2089 may be the in-memory one rather than the loaded+ // snapshot, and is pre-relocation either way; the store // keeps one backup per document and no-ops if one already exists. // A backup write failure blocks the relocation write and surfaces // the notes-error path (design Error Handling).@@ -345,6 +367,101 @@ final class NotesManager { } } + /// The notes a resuming load should apply: its own snapshot, unless the+ /// in-memory container moved on while the load was suspended (T-2089).+ ///+ /// A mutation that lands in that window is invisible to `loadGeneration` and+ /// to the cached-path check, so without this the load republishes state that+ /// predates the mutation: the note disappears from `documentNotes`/+ /// `anchoredNotes`, and a load that also relocates writes the stale set back+ /// to the store from `saveCurrentState()`, losing the note on disk too.+ ///+ /// Deferring to memory is safe because memory is *derived from* the store+ /// rather than a divergent branch of it. Check a writer of `documentNotes`+ /// against the question, not against a roster — the roster has been written+ /// down with a wrong count more than once. The question is: *does what it+ /// publishes reach the store, or does it carry an identifier this load does+ /// not own?* The writers that can move the container inside this load's+ /// window answer it three ways. (The remaining ones need no argument: this+ /// function and `clearNoteState` are the load itself, ordered by the+ /// generation guard; `noteContainer` publishes what it just read from the+ /// store; `saveCurrentState` ends in `persistNotes`.)+ ///+ /// - The creation paths — `createAndPersistNote`, `createReply` and+ /// `handleDocumentNoteCreation` — take their baseline from+ /// `noteContainer(for:)`, which reads the store itself, and persist what+ /// they append before returning. A container they moved therefore equals+ /// what this load read plus mutations already on disk.+ /// - The in-place mutators — `updateNote`, `deleteNote`, `toggleStatus`,+ /// `clearResolved` and `reattachNote` — never read the store; their+ /// baseline *is* the in-memory container. Each publishes its mutation and+ /// then ends in `await persistNotes(notes)`, so what it published is on+ /// its way to the store: memory is the store's near future, and this+ /// load's snapshot is its past. That, and not their `documentNotes == nil`+ /// early return, is what covers them — they really can run inside a load.+ /// `loadNotes` clears the container *before its store await* only on its+ /// `guard iCloudAvailable` branch (its other `clearNoteState()` runs after+ /// that await), so a document switch runs the new document's *first* load+ /// with the previous container still in memory; and a creation inside this+ /// load's own window publishes a container (`noteContainer`) for a mutator+ /// to work on. Neither is gated on `isLoading`.+ /// - `migrateNotes` answers the second half instead: it republishes the+ /// container under a *different* `identifier` (before its save, so the+ /// convergence argument does not reach it). It needs neither — the+ /// identifier check below declines that container and this load applies+ /// its own result, which is the correct T-1811 behaviour. Its save-failure+ /// revert answers *neither* half — it republishes under the *source*+ /// identifier and never persists — but it restores the container to the+ /// value it held before the migration, so a source-document load in flight+ /// finds `current == baseline`, the guard declines, and that load applies+ /// its own result. That is the one writer here whose safety rests on the+ /// equality rather than on the question.+ ///+ /// For the first two groups this is the mirror of `noteContainer`'s own+ /// re-read across its store await — whichever of the two resumes last defers+ /// to what the other published. A future writer that can answer neither half+ /// — publishing state under this load's own identifier that never reaches+ /// the store — would reopen this bug and needs its own argument here.+ ///+ /// **Constraint: `NotesStore.load` and `save` must not gain a suspension+ /// point.** The converse premise — memory *unchanged* means the loaded+ /// snapshot is at least as fresh as memory — holds only because both are+ /// `async` yet contain no `await`, so actor isolation serialises them and a+ /// save enqueued before a load is always visible to that load. Give either an+ /// internal await — an `NSFileCoordinator` for iCloud is the live candidate,+ /// proposed by the multi-window notes work in T-1723/T-1895 — and a mutation+ /// that publishes *before* this load captures its baseline but whose write+ /// lands *after* the load's read reopens this bug in a shifted window:+ /// `current == baseline`, the guard does not fire, the stale snapshot wins.+ /// `NotesStore.loadFromCurrentPath` carries the sibling caution for the same+ /// reason; the two have to be maintained together.+ ///+ /// The comparison is against a captured baseline rather than a mutation+ /// counter deliberately: `documentNotes` is assigned from many places, and a+ /// hand-bumped counter reopens this exact bug silently the first time one of+ /// them is forgotten. A `didSet` *could* automate the bump —+ /// `@Observable` keeps observing a stored property that carries+ /// `willSet`/`didSet`; only genuinely computed get/set properties drop out of+ /// observation, which is why `AppSettings` re-adds `access`/`withMutation` by+ /// hand for those — but an automated counter carries no information the value+ /// comparison does not already carry, so it would be a second piece of state+ /// to keep in step for no gain.+ ///+ /// The identifier check keeps this narrow: a container for *another*+ /// document is not fresher truth about this one, it is the T-1811 hazard,+ /// and the existing behaviour of applying this load's own result is correct+ /// there.+ private func applicableNotes(+ loaded: DocumentNotes?,+ baseline: DocumentNotes?,+ identifier: DocumentIdentifier+ ) -> DocumentNotes? {+ guard let current = documentNotes,+ current.identifier == identifier,+ current != baseline else { return loaded }+ return current+ }+ /// Check if any notes were relocated during relocation. private func clearNoteState() { documentNotes = nil
diff --git a/prism/Services/NotesStore.swift b/prism/Services/NotesStore.swiftindex 12486cb..ce7c211 100644--- a/prism/Services/NotesStore.swift+++ b/prism/Services/NotesStore.swift@@ -87,6 +87,10 @@ actor NotesStore: NotesStoreProtocol { /// legacy file using the old slash-to-underscore encoding and migrates it /// to the new filename (T-459). /// - Requirement: 1.4+ /// - Important: `async` but with no internal `await` — two callers depend on+ /// that. Before adding a suspension point here (an `NSFileCoordinator` for+ /// T-1723/T-1895 is the live candidate), read the T-2089 caution in+ /// `loadFromCurrentPath`; the same applies to `save`. func load(for identifier: DocumentIdentifier) async -> DocumentNotes? { guard let url = fileURL(for: identifier) else { return nil } @@ -115,6 +119,15 @@ actor NotesStore: NotesStoreProtocol { // Synchronous method — no suspension points between decode failure and // quarantine, so actor isolation keeps load and save mutually exclusive. // Promoting this method to `async` would invalidate that guarantee.+ //+ // A second caller depends on the same property: `load` and `save`+ // being `async` with no internal `await` is what lets+ // `NotesManager.applicableNotes` (T-2089) read "in-memory container+ // unchanged" as "the loaded snapshot is at least as fresh". Adding a+ // suspension point here or in `save` — an `NSFileCoordinator` for the+ // multi-window/iCloud work in T-1723/T-1895 is the live candidate —+ // reopens that race in a shifted window and needs a fix on that side+ // too, not just here. logger.error( "Corrupt notes file at current path \(identifier.path, privacy: .public): \(String(describing: error), privacy: .public)" )@@ -186,6 +199,10 @@ actor NotesStore: NotesStoreProtocol { /// Save notes to iCloud. /// Also cleans up any orphaned legacy file for this identifier (T-459). /// - Requirement: 1.5+ /// - Important: `async` but with no internal `await` — two callers depend on+ /// that. Before adding a suspension point here (an `NSFileCoordinator` for+ /// T-1723/T-1895 is the live candidate), read the T-2089 caution in+ /// `loadFromCurrentPath`; the same applies to `load`. func save(_ notes: DocumentNotes) async throws { guard let url = fileURL(for: notes.identifier) else { throw NSError(
diff --git a/prismTests/NotesManagerLoadRaceTests.swift b/prismTests/NotesManagerLoadRaceTests.swiftindex 4084794..7719afc 100644--- a/prismTests/NotesManagerLoadRaceTests.swift+++ b/prismTests/NotesManagerLoadRaceTests.swift@@ -49,6 +49,12 @@ struct NotesManagerLoadRaceTests { } func load(for identifier: DocumentIdentifier) async -> DocumentNotes? {+ // Snapshot BEFORE the delay: a load returns what the store held when it+ // was called, and the delay models a slow return, not a late read.+ // Reading after the sleep would silently launder writes that landed+ // during it into the "loaded" result and hide every load-versus-write+ // race this file exists to pin (T-2089).+ let snapshot = storedNotes[identifier.path] if identifier.path == delayPath { if delayFirstLoadOnly { if !hasDelayedFirstLoad {@@ -59,7 +65,7 @@ struct NotesManagerLoadRaceTests { try? await Task.sleep(for: delayDuration) } }- return storedNotes[identifier.path]+ return snapshot } func save(_ notes: DocumentNotes) async throws {@@ -397,4 +403,251 @@ struct NotesManagerLoadRaceTests { #expect(manager.anchoredNotes[oldBlock.id] == nil) #expect(manager.importedNotes[newBlock.id]?.count == 1) }++ // MARK: - Load vs. Mutation Race (T-2089)++ /// T-2089: `loadGeneration` only orders load against *load*. A note created+ /// while a load is parked in `store.load` leaves the generation untouched, so+ /// the load's guards pass and it publishes a snapshot that predates the note —+ /// dropping it from memory, and (when the load relocates) writing the stale set+ /// over it in the store.+ ///+ /// This is the nil-result half: the store held nothing for the document, so the+ /// resuming load takes the `clearNoteState()` branch and wipes the new note.+ @Test("Note created during an in-flight load survives a nil store result")+ @MainActor+ func noteCreatedDuringLoadSurvivesNilStoreResult() async {+ let store = DelayedNotesStore()++ let url = URL(fileURLWithPath: "/Users/test/project/specs/doc.md")+ let path = "project/specs/doc.md"+ let block = makeBlock("Document content")++ // Nothing stored for this document — the load resolves to nil.+ // Only the load's own read is delayed; the creation's read resolves at once.+ await store.setFirstLoadDelay(for: path)++ let manager = NotesManager.makeForTesting(store: store)+ let sessionID = UUID()++ let load = Task {+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])+ }++ // Let the load reach its delayed store read, then create a note.+ try? await Task.sleep(for: .milliseconds(50))+ await manager.createDocumentNote(+ content: "Created while loading", source: .file(url: url), sessionID: sessionID+ )+ #expect(manager.documentNotes?.notes.count == 1)++ await load.value++ // The load must not publish its pre-await nil over the newer note.+ #expect(manager.documentNotes?.notes.count == 1)+ #expect(manager.documentNotes?.notes.first?.content == "Created while loading")+ #expect(manager.anchoredNotes[BlockNote.documentSentinelId]?.count == 1)+ }++ /// T-2089: the loaded-snapshot half. The store held a note, so the resuming+ /// load publishes that snapshot — which predates the note created during the+ /// await — and the new note disappears from memory.+ @Test("Note created during an in-flight load is not overwritten by the loaded snapshot")+ @MainActor+ func noteCreatedDuringLoadSurvivesLoadedSnapshot() async {+ let store = DelayedNotesStore()++ let url = URL(fileURLWithPath: "/Users/test/project/specs/doc.md")+ let path = "project/specs/doc.md"+ let block = makeBlock("Document content")++ let storedNote = makeNote(blockId: block.id, content: "Document content")+ await store.preload(makeDocumentNotes(+ path: path, displayName: "doc.md", notes: [storedNote]+ ))+ await store.setFirstLoadDelay(for: path)++ let manager = NotesManager.makeForTesting(store: store)+ let sessionID = UUID()++ let load = Task {+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])+ }++ try? await Task.sleep(for: .milliseconds(50))+ await manager.createDocumentNote(+ content: "Created while loading", source: .file(url: url), sessionID: sessionID+ )++ await load.value++ // Both the stored note and the one created mid-load must be present.+ #expect(manager.documentNotes?.notes.count == 2)+ #expect(manager.documentNotes?.notes.contains { $0.content == "Created while loading" } == true)+ #expect(manager.anchoredNotes[BlockNote.documentSentinelId]?.count == 1)+ #expect(manager.anchoredNotes[block.id]?.count == 1)+ }++ /// T-2089, the data-loss half: the resuming load relocates the stale snapshot+ /// and runs `saveCurrentState()`, which writes that snapshot over the store+ /// entry the mid-load creation had already persisted. The note is then gone+ /// from disk too, not just from memory.+ @Test("Relocation save from a resuming load does not delete a note created during it")+ @MainActor+ func relocationSaveDoesNotDeleteNoteCreatedDuringLoad() async {+ let store = DelayedNotesStore()++ let url = URL(fileURLWithPath: "/Users/test/project/specs/doc.md")+ let path = "project/specs/doc.md"+ let block = makeBlock("Old block content")++ // Anchored to a stale block ID but quoting text close enough to fuzzy-match,+ // so the load relocates and takes the backup + saveCurrentState tail.+ let storedNote = makeNote(blockId: "stale-block-id", content: "block content")+ await store.preload(makeDocumentNotes(+ path: path, displayName: "doc.md", notes: [storedNote]+ ))+ await store.setFirstLoadDelay(for: path)++ let manager = NotesManager.makeForTesting(store: store)+ let sessionID = UUID()++ let load = Task {+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])+ }++ try? await Task.sleep(for: .milliseconds(50))+ await manager.createDocumentNote(+ content: "Created while loading", source: .file(url: url), sessionID: sessionID+ )++ // The creation persisted both notes before the load resumes.+ let persistedDuringLoad = await store.storedNotes[path]?.notes.count+ #expect(persistedDuringLoad == 2)++ await load.value++ // Guard against a vacuous pass: if the stored note stopped relocating, the+ // save tail would never run and the assertion below would hold regardless.+ #expect(manager.anchoredNotes[block.id]?.count == 1)++ let persistedAfterLoad = await store.storedNotes[path]?.notes ?? []+ #expect(persistedAfterLoad.count == 2)+ #expect(persistedAfterLoad.contains { $0.content == "Created while loading" })+ }++ /// T-2089, the in-place-mutator half. The creation paths take their baseline+ /// from `noteContainer(for:)`, which reads the store; the in-place mutators+ /// (`updateNote`, `deleteNote`, `toggleStatus`, `clearResolved`,+ /// `reattachNote`) never read the store and take their baseline from+ /// `documentNotes`. Their safety argument is therefore a different one —+ /// each ends in `persistNotes`, so what they publish is on its way to the+ /// store — and it needs its own coverage. A delete landing during a *reload*+ /// is the shape: the load's snapshot still holds the deleted note, so+ /// republishing it resurrects one the user just removed.+ ///+ /// What this pins is the **deferral** — return `loaded` instead of `current`+ /// (pre-fix behaviour) and the reload brings `doomed` back. It does *not*+ /// pin the `current != baseline` clause: dropping that clause makes memory+ /// win unconditionally, which is exactly what the assertions below want, so+ /// this test would still pass. `reloadWithoutMutationAppliesStoreSnapshot`+ /// is the one that pins that clause. (The commit that added this test+ /// claimed the opposite; the two tests' roles are not interchangeable.)+ @Test("Note deleted during an in-flight reload is not resurrected by the loaded snapshot")+ @MainActor+ func noteDeletedDuringReloadIsNotResurrected() async {+ let store = DelayedNotesStore()++ let url = URL(fileURLWithPath: "/Users/test/project/specs/doc.md")+ let path = "project/specs/doc.md"+ let block = makeBlock("Document content")++ let keep = makeNote(blockId: block.id, content: "Document content")+ let doomed = makeNote(blockId: block.id, content: "Document content")+ await store.preload(makeDocumentNotes(+ path: path, displayName: "doc.md", notes: [keep, doomed]+ ))++ let manager = NotesManager.makeForTesting(store: store)+ let sessionID = UUID()++ // First load is undelayed, so the reload below starts with a non-nil+ // `documentNotes` — the state the in-place mutators require.+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])+ #expect(manager.documentNotes?.notes.count == 2)++ // Delay the reload's store read so the delete lands inside its window.+ await store.setDelay(for: path)++ let reload = Task {+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])+ }++ try? await Task.sleep(for: .milliseconds(50))++ // Guard against a vacuous pass: the window is a 200 ms store delay+ // against this 50 ms sleep, and if the delete landed *after* the reload+ // had applied its snapshot the sequence would be [keep, doomed] →+ // delete → [keep] and every assertion below would still hold, pinning+ // nothing. `isLoading` is true only between `loadNotes`' iCloud guard+ // and its return, so this fails rather than passes silently if the+ // delete misses the window. It is exact here only because the stored+ // notes already anchor to `block`, so the reload does not relocate and+ // has no await between publishing its snapshot and returning — `isLoading`+ // therefore still means "parked in `store.load`". Give this test a+ // relocating fixture and the flag would stay true across the backup tail+ // too, weakening the guard to "has not returned yet".+ #expect(manager.isLoading)++ await manager.deleteNote(doomed.id)+ #expect(manager.documentNotes?.notes.count == 1)++ await reload.value++ // The reload read both notes before the delete, so applying its snapshot+ // would bring the deleted note back in memory and, on the next write, on+ // disk. Memory must win.+ #expect(manager.documentNotes?.notes.count == 1)+ #expect(manager.documentNotes?.notes.contains { $0.id == doomed.id } == false)+ #expect(manager.documentNotes?.notes.contains { $0.id == keep.id } == true)+ #expect(manager.anchoredNotes[block.id]?.count == 1)++ let persisted = await store.storedNotes[path]?.notes ?? []+ #expect(persisted.count == 1)+ #expect(persisted.contains { $0.id == doomed.id } == false)+ }++ /// The T-2089 guard defers to memory only when memory actually moved. A plain+ /// reload with no concurrent mutation must still apply what the store holds,+ /// or the fix would turn every reload into a no-op.+ @Test("Reload without a concurrent mutation still applies the store snapshot")+ @MainActor+ func reloadWithoutMutationAppliesStoreSnapshot() async {+ let store = MockNotesStore()++ let url = URL(fileURLWithPath: "/Users/test/project/specs/doc.md")+ let path = "project/specs/doc.md"+ let block = makeBlock("Document content")++ let first = makeNote(blockId: block.id, content: "Document content")+ await store.preload(makeDocumentNotes(+ path: path, displayName: "doc.md", notes: [first]+ ))++ let manager = NotesManager.makeForTesting(store: store)+ let sessionID = UUID()+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])+ #expect(manager.documentNotes?.notes.count == 1)++ // The store gains a second note out of band (e.g. an iCloud sync), then the+ // document is reloaded with no in-memory mutation in between.+ let second = makeNote(blockId: block.id, content: "Document content")+ await store.preload(makeDocumentNotes(+ path: path, displayName: "doc.md", notes: [first, second]+ ))+ await manager.loadNotes(source: .file(url: url), sessionID: sessionID, blocks: [block])++ #expect(manager.documentNotes?.notes.count == 2)+ #expect(manager.anchoredNotes[block.id]?.count == 2)+ } }
diff --git a/docs/agent-notes/notes-system.md b/docs/agent-notes/notes-system.mdindex d582180..54b4f25 100644--- a/docs/agent-notes/notes-system.md+++ b/docs/agent-notes/notes-system.md@@ -155,6 +155,25 @@ Regression coverage is in `NotesManageriCloudSignInTests`, one test per guard: ` Its store read is a suspension point, so two creations that both arrive with nothing loaded (or with another document's container loaded) both reach it and both resume with a baseline read before the other's note existed. Whichever saves last would delete the other's note. The fix is the re-read of `documentNotes` *after* the store await, before `stored` is used: every creation path publishes its appended container to `documentNotes` synchronously — no `await` between `noteContainer` returning and the assignment — so a second caller resuming there sees the first note and builds on it. Keep that property when touching a creation path. Deduplicating the store read itself (one shared in-flight task) would save a redundant read but is not what makes this safe. Pinned by `concurrentCreationsWithNothingLoadedKeepBothNotes`. +## Load vs. Mutation (T-2089)++`loadNotes`' guards at the top of the function are all load-versus-load: `cachedDocumentPath` (T-369) separates documents, `loadGeneration` (T-1556/T-1586) orders reloads of one document. None of them sees a *mutation*. Creating a note bumps no generation and moves no cached path, so a note created while a load sat in `store.load` passed every guard and was then overwritten by the load's pre-await snapshot — and, when the load relocated, deleted from the store by `saveCurrentState()`. The window is ordinary: note creation is gated on `iCloudAvailable`, never on `isLoading`, so the document is fully interactive while its notes load.++`applicableNotes(loaded:baseline:identifier:)` closes it. `loadNotes` captures `documentNotes` as `baselineNotes` immediately before the store await; if the container is for the same document and no longer equals that baseline, it superseded the snapshot and is what gets relocated and applied. Four things about it are easy to get wrong:++- **Deferring to memory is safe because memory is derived from the store, not a divergent branch of it — but the reason splits by writer group.** Check a writer against the question rather than against a roster; the roster has been written down with a wrong count more than once. The question: *does what it publishes reach the store, or does it carry an identifier this load does not own?* The *creation* paths (`createAndPersistNote`, `createReply`, `handleDocumentNoteCreation`) take their baseline from `noteContainer(for:)`, which reads the store itself, and persist what they append; a container they moved equals what the load read plus mutations already on disk. The *in-place* mutators (`updateNote`, `deleteNote`, `toggleStatus`, `clearResolved`, `reattachNote`) never touch the store — their baseline is the in-memory container — and converge because each ends in `persistNotes`: what they publish is on its way to the store, so memory is the store's near future and the load's snapshot is its past. Do not check the first argument against `deleteNote` and conclude the guard is broken; it is the second argument that covers it. **Do not reach for "the mutators no-op while `documentNotes == nil`, so they cannot run during an initial load" either — that premise is false in two reachable shapes,** and an earlier version of this note and of the code comment asserted it: `loadNotes` clears the container *before its store await* only on its `guard iCloudAvailable` branch (its other `clearNoteState()` runs after that await), so a document switch runs the new document's *first* load with the previous container still in memory; and a creation inside the load's own window publishes a container via `noteContainer` for a mutator to work on. Neither is gated on `isLoading`. The `persistNotes` convergence covers both, and the nil premise never applied to a *reload* anyway. `migrateNotes` answers the second half instead of the first — it republishes the container under a different identifier, before its save — so the identifier check declines it and the load applies its own result. Its save-failure revert answers *neither* half (it republishes under the *source* identifier and never persists), and is safe for a third reason: it restores the container to the value it held before the migration, so an in-flight source-document load finds `current == baseline` and applies its own result. For the first two groups this mirrors `noteContainer`'s post-await re-read: whichever of the two resumes last defers to what the other published, and between them the window is closed from both ends.+- **It depends on `NotesStore.load`/`save` never suspending internally.** The converse premise — memory *unchanged* means the loaded snapshot is at least as fresh — holds only because both are `async` with no internal `await`, so actor isolation serialises them and a save enqueued before a load is always visible to it. Add a suspension point and the bug reopens in a *shifted* window: a mutation that publishes before the load captures its baseline but whose write lands after the load's read leaves `current == baseline`, the guard does not fire, and the stale snapshot wins. This is a live forward hazard, not a hypothetical — the multi-window notes work in **T-1723/T-1895** proposes iCloud-appropriate `NSFileCoordinator` file coordination, which is exactly that change. `NotesStore.loadFromCurrentPath` carries the sibling caution (quarantine-vs-save exclusivity) and now cross-references this one; maintain the two together.+- **Merging the two by note id is wrong, not merely redundant.** A delete that lands after the load's read leaves the note present in the snapshot and absent from memory, so a union resurrects it. There is no per-note version information to arbitrate with.+- **Capture-and-compare, not a mutation counter.** `documentNotes` is assigned from many places and a hand-bumped counter reopens this bug silently the first time one is forgotten. A `didSet` *could* automate the bump — verified on Swift 6.3.3 / Xcode 26.6, `@Observable` keeps observing a stored property carrying `willSet`/`didSet` and the observer still fires; only genuinely computed get/set properties drop out, which is why `AppSettings` re-adds `access(keyPath:)`/`withMutation(keyPath:)` by hand for those. (An earlier version of this note and of the code comment claimed the opposite — do not reuse that claim.) The counter is rejected on a different ground: automated or not, it carries no information the value comparison does not already carry, so it is a second piece of state to keep in step for no gain.++Equality is what makes the compare reliable, and it rests on an unwritten-until-now invariant: **every mutator stamps `notes.modifiedAt` before publishing.** `DocumentNotes` is synthesised `Equatable` over an order-sensitive array, so a mutator that publishes without the bump and whose other fields land back where they started compares equal and defeats the guard. The invariant is recorded on the `documentNotes` declaration, where a mutator author will see it. A false *positive* (differing when it need not) is harmless — memory wins, already the safe direction.++The identifier check keeps it narrow: a container for another document is not fresher truth about this one, it is the T-1811 hazard, and applying the load's own result is right there. It also cannot be defeated by a mid-load document switch, because a switch runs `loadNotes`, whose prologue bumps `loadGeneration` before any await — the switch case is retired by the generation guard well before `applicableNotes` runs. `migrateNotes` is the one writer that moves cached identity *without* bumping the generation. It does rewrite `cachedDocumentPath`, so a load for the *source* document that resumes after that rewrite is retired by the path guard — but **the two writes straddle the save**: in `migrateNotes`, the container is rebound to the target identifier (`documentNotes = notes`, right after `notes.identifier = targetIdentifier`) *before* `await store.save`, while `cachedDocumentPath = targetIdentifier.path` runs only *after* it, past the T-1812 re-check. (Line numbers were cited here originally and were stale within the same commit that added them — describe the anchors, not the lines.) A source-identifier load resuming inside that window passes both guards, and `applicableNotes` returns `loaded` (the container's identifier is now the target, so the identifier check declines it), applying the pre-migration snapshot over the rebinding. That is unchanged from pre-T-2089 behaviour — the old code overwrote unconditionally — so it is a pre-existing window this guard neither opens nor closes, not a regression; do not cite the path guard as covering it. The identifier-versus-generation asymmetry is the same one already called out for T-1811.++Regression coverage in `NotesManagerLoadRaceTests`: `noteCreatedDuringLoadSurvivesNilStoreResult` (the `clearNoteState()` branch), `noteCreatedDuringLoadSurvivesLoadedSnapshot` (the publish branch), `relocationSaveDoesNotDeleteNoteCreatedDuringLoad` (the disk loss), `noteDeletedDuringReloadIsNotResurrected` (the in-place-mutator group, via `deleteNote` — every other test drives a creation path), and `reloadWithoutMutationAppliesStoreSnapshot` so the guard cannot be widened into "loads never apply anything". The last two pin *different halves* and are easy to conflate: the deletion test pins the **deferral** (return `loaded` and the deleted note comes back), while only `reloadWithoutMutationAppliesStoreSnapshot` pins the **`current != baseline` clause** — dropping that clause makes memory win unconditionally, which the deletion test asserts anyway, so it would still pass.++**`DelayedNotesStore.load` must snapshot before it sleeps.** It used to read `storedNotes` after the artificial delay, which models a load that observes writes made while it was suspended — laundering the mid-load creation into the "loaded" result and hiding every load-versus-write race in the file. All three T-2089 tests passed against the unfixed code until that was corrected.+ ## Session Change State Reset (T-407) Shared session-scoped state lives in `DocumentLayoutCoordinator` and is reset via `coordinator.resetSessionState()`, called from both layouts' `.onChange(of: session.id)` handlers. Add new shared session-scoped state to `DocumentLayoutCoordinator.resetSessionState()`. Layout-specific state (e.g. sidebar visibility, search overlay) remains in each layout's `onChange` handler.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 2814e59..4de52ef 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -92,6 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Notes: rapidly reloading the same document no longer lets an older reload back up or persist notes state that a newer reload has already replaced. The load-generation guard is now re-checked after the backup step, before the state is saved. - Opening a file from the Recents list while a pasted (clipboard) document has unsaved content now shows the Unsaved Changes confirmation instead of silently replacing the unsaved document. Previously only bundled and URL-sourced recents were guarded; regular file recents skipped the dialog. Save and Discard both continue on to open the chosen recent file (T-1630). - HTML comments embedded in paragraph text (and in headings, list items, and table cells) render again as dimmed inline annotations when "Show HTML comments" is on — they were silently dropped by the new rendering engine (T-1638). Stand-alone comments nested inside blockquotes or list items now get the same toggleable annotation treatment instead of leaking as always-visible plain text, and comment annotations regained their info indicator glyph. Search now stays aligned with what is actually rendered: comment shapes that are never displayed (conditional comments, note-infrastructure tags) no longer count as matches when the toggle is on, and hidden comment text nested inside blockquotes or list items no longer produces phantom highlights.+- A note written while a document's notes are still loading is no longer thrown away when the load finishes (T-2089). Notes can be added the moment a document appears, but loading them from iCloud takes a moment longer — and a note added in that gap vanished from the screen as soon as the load landed, because the load put back the set of notes it had read before your note existed. Where that load also had to re-attach notes to moved text, it saved that older set back to iCloud too, so the note was gone for good rather than just off-screen until the next reopen. The load now recognises when the notes have moved on beneath it and keeps what is on screen — your note and everything already stored — instead of replacing it. A reload with nothing added still picks up whatever iCloud holds, as before. ### Security
The in-repo copies are fixed and committed, but the PR description on GitHub still cites NotesManager.swift:1096 / line 1120, still says "assigned from a dozen-odd places", and still says "four tests" above a list of five. Since the body is the artefact a reviewer reads first, it should be edited to match before merge.
make test-quick reports total=4444 passed=4214 failed=191. Every one of those 191 failure records carries the failure text Test crashed with signal abrt — there are zero assertion failures in the bundle. This is the known T-2219 crasher (MermaidCSPSpikeTests aborts the test host) cascading into everything still queued. Confirmed by re-running the eight notes suites in isolation: 69/69, 0 failed, including all 9 NotesManagerLoadRaceTests. Do not read the full-suite number as a regression signal until T-2219 is fixed.
GitHub Actions on this repo is billing-blocked and Linux-only — it cannot compile a Swift/SwiftUI iOS+macOS target, let alone run these tests. The local macOS run recorded here is not a supplement to CI; it is the only verification this change will receive. Both builds green, lint 0/541, notes suites 69/69 read from the result bundle via Tools/check-test-results.sh.
The mock read the store after its artificial delay for as long as it has existed, which makes any load-versus-write race in this file unreproducible. The three new T-2089 tests all passed against unfixed code until it was corrected. The other tests in NotesManagerLoadRaceTests were written against that same broken mock — worth a pass to confirm each still pins what it claims now that the mock behaves like a real store.
Main has moved to 0f7ef0c. The only overlapping file is CHANGELOG.md; git merge-tree produces a clean tree with no conflicts. No rebase is required before pushing.
T-1723/T-1895 proposes NSFileCoordinator coordination inside NotesStore.load/save. That change reopens this bug in a shifted window where current == baseline and the guard cannot fire. Whoever picks up that ticket must read the - Important: notes on both methods first and fix this side too.