prism branch T-1811/bugfix-… commits 3 files 5 touched lines +557 / -20 review agents 3 (reuse, quality, efficiency)

Pre-push review: T-1811 iCloud sign-in overwrites notes

PR #359 — data-loss fix: signing into iCloud with a document open could silently replace every stored note for that document with the first note added afterwards. Third review round: A→B→A generation capture, guard-2 reclassification, and the noteContainer reentrancy fix.

At a glance

  • Root cause: opening a document while signed out of iCloud skips reading its notes; signing in re-enables every mutation path, and all three note-creation paths fell back to documentNotes ?? DocumentNotes(…) — a fabricated empty container that NotesStore.save then wrote atomically over the stored file.
  • Fix shape: unavailable→available is now a reload boundary (applyiCloudAvailabilitystartSignInReload), and noteContainer(for:) becomes the single creation-baseline source: join the reload, validate container identity, hydrate from the store before ever fabricating an empty container.
  • Three race guards, each with a distinct job and each mutation-pinned by exactly one test: the reload task's entry guard (identifier + loadGeneration, closing the A→B→A window), loadNotes's retirement of the reload handle (wait-avoidance only), and noteContainer's identity check + post-await re-read (the correctness backstop).
  • New test file NotesManageriCloudSignInTests (8 tests) plus an agent-note section documenting the invariant: never reintroduce a ?? container fallback at a write site.
  • GitHub Actions is billing-blocked; validation is local — SwiftLint clean (0 violations in 525 files). The full make test-quick run crashed mid-way in a WebKit-hosted test (NSException inside WebKit, ~212 queued tests never ran, ~0 genuine failures); a targeted re-run of all 14 NotesManager*/notes-sync classes, including the new suite, passes.
  • Two doc-only fixes applied during this review (dangling migrateClipboardNotes reference; noteContainer first-note case) — commit the working tree before pushing.

Verdict

Ready to push

No blocking, critical, or major findings from any of the three review agents on this fourth-round pass. The guard structure is coherent, every creation path was verified to route through noteContainer(for:) and publish synchronously, and each race guard is mutation-pinned by exactly one named test. Two minor documentation findings were fixed in the working tree (a dangling symbol reference raised independently by two agents, and a doc-comment gap); the remaining suggestions are explicitly take-it-or-leave-it and were skipped with recorded justification. SwiftLint is clean and all 14 NotesManager*/notes-sync test classes pass, including the new 8-test suite. The full make test-quick run hit a WebKit test-host crash cascade (~212 queued tests never ran, ~0 genuine failures) that is unrelated to this NotesManager-only diff. The working-tree doc fixes need to be committed before pushing.

Review findings

4 raised · 2 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism stores your notes in iCloud. If you open a document while signed out of iCloud, the app cannot read the notes that already exist for it — it knows which document you have open, but not what notes it has. Before this fix, signing into iCloud at that point silently re-enabled note-taking without ever going back to fetch the stored notes. The first note you then added was saved as if it were the document's only note, wiping out every note the document already had — because saving replaces the whole notes file at once.

The fix does three things: the moment you sign in, the app immediately re-reads the open document's notes so they reappear; before any new note is saved, the app always establishes a correct starting point (if nothing has been read yet, it reads what is stored first and adds the new note on top); and it handles the awkward timing windows — adding a note in the instant after signing in, switching documents mid-reload, or adding two notes at the same moment.

Why It Matters

This was silent data loss. A user with dozens of notes on a document could lose all of them by doing something completely reasonable: opening the file, signing into iCloud, and jotting one note.

Key Concepts

  • Atomic replace: saving notes overwrites the document's entire notes file — like retyping a whole page to add one sentence. Retype from a blank page and everything else on it is gone.
  • “Nothing loaded” vs “nothing exists”: an empty in-memory state can mean either “we never looked” or “there is truly nothing”. Only asking the storage can tell them apart; the old code conflated the two.
  • Race condition: two things happening at nearly the same time (the sign-in reload vs. adding a note; two notes at once) where the outcome depends on which finishes first.

Changes Overview

Two production files: prism/Services/NotesManager.swift (+162/−13, including this review's doc fixes) and prism/Services/NotesManager+DocumentLevel.swift (+2/−7); a new test file prismTests/NotesManageriCloudSignInTests.swift (8 tests); agent-notes and CHANGELOG updates.

Implementation Approach

  • Sign-in reload boundary: checkiCloudAvailability and the DEBUG setiCloudAvailable both route through a new applyiCloudAvailability(_:), which treats unavailable→available as a reload boundary and calls startSignInReload(). The reload is a scheduled @MainActor task (signInReloadTask) that re-runs loadNotes for the cached document using a new cachedBlocks ivar — retained even when the load was skipped, because that skipped load is exactly what the reload recovers from.
  • Single creation baseline: the three creation paths (createAndPersistNote, createReply, handleDocumentNoteCreation) previously fell back to documentNotes ?? DocumentNotes(…) — the fabricated-empty-container bug. All three now call noteContainer(for:), which joins any pending sign-in reload, validates that the loaded container belongs to the requested document, and otherwise hydrates from NotesStore before ever fabricating an empty container.
  • Race guards: startSignInReload captures (identifier, blocks, loadGeneration) and its task abandons itself if either changed by the time it runs (the generation half closes the A→B→A revisit window); loadNotes's prologue retires signInReloadTask on a document switch (wait-avoidance only); noteContainer's identity check plus post-await re-read make the write safe regardless of how a mismatch or interleaving arose.

Trade-offs

  • The reload joins the store's normal loadNotes path (full relocation) rather than a cheaper merge — correctness over micro-optimisation, and it reuses the existing pipeline.
  • noteContainer's reentrancy is solved by a re-read after the suspension rather than deduplicating the store read with a shared in-flight task; the redundant read is accepted and the doc comment says so explicitly.
  • hasPendingSignInReload and the routed setiCloudAvailable are DEBUG test seams so each guard is pinnable by a test that exercises the real transition.

Technical Deep Dive

The subtle part is that loadNotes's existing loadGeneration guard does not protect against the sign-in reload racing a document switch: the prologue rewrites cachedDocumentPath/cachedDocumentIdentifier/cachedBlocks and bumps the generation before the first await, so whichever top-level task's prologue runs last wins — a stale reload can rebind the manager to a departed document. The entry guard in startSignInReload's task closes this; identifier equality alone is insufficient because A→B→A re-satisfies it with blocks two loads stale, hence the monotonic generation check. The identifier is still checked separately because migrateClipboardNotes moves cached identity without bumping the generation.

noteContainer(for:)'s post-await re-read is load-bearing: the store read suspends, two concurrent creations can both pass the nil check, and whichever saved last would atomically delete the other's note. Safety relies on an invariant — every creation path publishes its appended container to documentNotes synchronously (no await) between noteContainer returning and the assignment. Verified during this review at NotesManager.swift:688, :821, and NotesManager+DocumentLevel.swift:79.

The A→B→A regression test documents why it must sign out between the switches: with iCloud available, loadNotes suspends in the store and main-actor FIFO ordering lets the orphan reach its entry guard while B is still current, hiding the window it exists to pin.

Architecture Impact

noteContainer(for:) establishes “only the store can distinguish nothing-loaded from nothing-exists” as an invariant with a single enforcement point; the agent-note explicitly forbids reintroducing the ?? fallback at write sites. iCloudAvailable is reframed from a UI-enablement flag to a precondition on whether the current document's notes have been read — that reframing is the root-cause understanding.

Potential Issues

  • Edit/resolve/delete paths (documentNotes writes at NotesManager.swift:841–997) still operate on the already-loaded container without noteContainer — safe today because they require an existing loaded note to act on, but a future “edit by id without load” path would reopen the hazard.
  • cachedBlocks retains the block array for the manager's lifetime; it is a COW reference to the session's array, so cost is bounded, but it is never proactively cleared.
  • The reload task holds [weak self]; no retain cycle.

Important changes — detailed

NotesManager: noteContainer(for:) as the single creation baseline

prism/Services/NotesManager.swift

Why it matters. This is the data-safety core. NotesStore.save replaces the identifier's file atomically, so any write site that fabricates an empty DocumentNotes silently deletes every stored note. All three creation paths now source their baseline here.

What to look at. NotesManager.swift:1039-1098 (noteContainer), call sites at :665, :803, NotesManager+DocumentLevel.swift:60

Takeaway. When a save is whole-file-atomic, 'nothing is loaded' and 'nothing exists' must be distinguished by consulting the store, never by a ?? fallback at the write site. Centralising the baseline in one resolver makes the invariant enforceable.
Rationale. A single enforcement point beats fixing three call sites independently: the doc comment and agent-note explicitly ban reintroducing the fallback, so future creation paths inherit the safety.

NotesManager: sign-in reload boundary (applyiCloudAvailability / startSignInReload)

prism/Services/NotesManager.swift

Why it matters. Restores the skipped load. Without it, notes stored in iCloud stay invisible until the document is closed and reopened, and the availability flag re-enables mutations against unread state.

What to look at. NotesManager.swift:122-170 (applyiCloudAvailability, startSignInReload), cachedBlocks at :90

Takeaway. An availability flag that gates whether state was ever read is a reload boundary, not just a UI toggle. Treat the unavailable-to-available edge as an event, and retain the inputs (cachedBlocks) needed to replay the skipped work.
Rationale. The reload reuses the full loadNotes pipeline (store read + relocation) rather than a bespoke merge, keeping one code path for how notes become loaded.

startSignInReload: entry guard captures (identifier, blocks, loadGeneration)

prism/Services/NotesManager.swift

Why it matters. Closes the document-switch race, including the A-B-A revisit. A stale reload whose prologue ran last would rebind the manager to the departed document and, being the newest generation, win.

What to look at. NotesManager.swift:186-197

Takeaway. Identity checks alone cannot retire stale async work when identity can be revisited; a monotonic generation counter can. Capture both when scheduling, compare both at entry.
Rationale. loadGeneration is monotonic so any intervening load retires the reload for good; the identifier is still checked because migrateClipboardNotes moves cached identity without starting a load (generation unchanged).

noteContainer reentrancy: re-read documentNotes after the store await

prism/Services/NotesManager.swift

Why it matters. Two concurrent creations with nothing loaded both suspend in the store read; without the re-read, whichever saved last would atomically delete the other's note.

What to look at. NotesManager.swift:1085-1091

Takeaway. In actor-isolated async code, every await is a reentrancy point: re-validate cached state after each suspension before using a value read before it. The safety here depends on a stated invariant - creation paths publish documentNotes synchronously after noteContainer returns.
Rationale. The re-read was chosen over deduplicating the store read with a shared in-flight task; the redundant read is accepted, and the doc comment records that dedup would not be the thing that makes this safe.

loadNotes prologue: retire the pending reload on document switch

prism/Services/NotesManager.swift

Why it matters. Wait-avoidance, explicitly not a correctness backstop: cancel() does not stop a task that never checks Task.isCancelled. Clearing the ivar stops noteContainer joining a reload whose result is already irrelevant.

What to look at. NotesManager.swift:256-272

Takeaway. Cancelling a Swift Task is cooperative; if the task body never checks cancellation, cancel() is a no-op on its execution. Say so in the comment - this one does, and classifies itself honestly as wait-avoidance.
Rationale. Reclassified in review round 3: an earlier round treated this as a race guard; the comment now records that what actually stops the stale reload is its own entry guard.

NotesManageriCloudSignInTests: one mutation-pinning test per guard

prismTests/NotesManageriCloudSignInTests.swift

Why it matters. Eight tests covering the base data-loss scenario, both halves of the entry guard (identity and generation), the ivar retirement, container-identity safety, and concurrent creation. Each guard fails its test if removed.

What to look at. prismTests/NotesManageriCloudSignInTests.swift:1-367, notably returningToTheOriginalDocumentDoesNotReviveTheOrphanedReload:618-664

Takeaway. The A-B-A test signs out between switches so the document switches take the non-suspending early return - otherwise main-actor FIFO ordering lets the orphan run its entry guard while the other document is current, hiding the exact window the test exists to pin. Scheduling-sensitive tests need their scheduling assumptions written down.
Rationale. DEBUG seams (setiCloudAvailable routing through the real transition, hasPendingSignInReload, awaitPendingNotesReload) exist precisely so each guard is independently observable.

Key decisions

Reload on sign-in rather than merge-on-save.

The unavailable→available transition schedules a full loadNotes for the cached document instead of merging stored notes into memory at save time. This reuses the existing load/relocation pipeline, makes the stored notes visible to the reader immediately, and keeps a single code path for how notes become loaded.

(inferred — not stated by the author.)
Retain cachedBlocks even when the load is skipped.

loadNotes caches the blocks before the guard iCloudAvailable bail, precisely because the skipped load is the case the sign-in reload has to recover from — relocation needs the blocks. MarkdownBlock is a value type, so this is a COW reference to the session's array, not a copy.

Generation + identifier in the reload's entry guard, not identifier alone.

Identity can be revisited (A→B→A), re-satisfying an identifier check while the captured blocks are two loads stale. loadGeneration is monotonic, so any intervening load retires the reload for good. The identifier is still checked because migrateClipboardNotes moves the cached identity without starting a load, leaving the generation unchanged.

Re-read after the store await instead of deduplicating the store read.

Two concurrent creations may both issue a store read; a shared in-flight task would save the redundant read but is explicitly documented as not the thing that makes the code safe. The post-await re-read of documentNotes is, because every creation path publishes its appended container synchronously.

Test seam routes through the production transition.

The DEBUG setiCloudAvailable(_:) now calls applyiCloudAvailability rather than setting the flag directly, so a test flipping availability exercises the real sign-in transition including the reload. hasPendingSignInReload pins the ivar retirement, whose only observable effect is clearing the handle.

Edit/resolve/delete paths left outside noteContainer.

Only the three creation paths were rewired. Mutation paths that require an already-loaded note to act on (updateNote, resolve, delete, …) keep operating on documentNotes directly — they cannot run without a loaded container, so the fabricated-container hazard does not arise there today.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorNotesManager.swift:185, notes-system.md:120Doc comment and agent-note both justify the identifier half of the reload entry guard by citing migrateClipboardNotes — a symbol that does not exist. The real method is migrateNotes(fromClipboardSession:toFileURL:) (NotesManager.swift:971). Raised independently by two agents: this is the entire stated reason the identifier check exists, and a reader grepping the cited name finds nothing and may conclude the check is dead.Renamed the reference to migrateNotes(fromClipboardSession:toFileURL:) in both places, keeping the load-bearing rationale greppable.
minorNotesManager.swift noteContainer doc commentThe most common creation scenario — first note on a document that loaded with iCloud available but had no stored notes — takes the store.load path even though loadNotes just proved the store empty (a nil load leaves documentNotes nil via clearNoteState). The redundant read is cheap and once-per-document, and eliminating it would require a 'loaded and empty' state that changes hasNotes and migrateNotes semantics — but the doc comment only framed the store read as recovering the sign-in case.Accepted the read (the alternative is riskier than what it saves) and extended the doc comment to name this case explicitly.
minorNotesManager creation-path tailsThe reentrancy invariant — every creation path publishes its appended container to documentNotes with no await between noteContainer returning and the assignment — is enforced by convention across three call sites, with slightly divergent tails (targeted anchoredNotes append at :688/:821 vs setDocumentNotes + rebuildAnchoredNotes in the document-level path). A shared publish helper would pin the invariant in one function body.Skipped. The tails differ deliberately (block-anchored notes update one anchor key; document-sentinel notes need the full rebuild), so a helper needs a mode switch; all three sites were verified compliant during this review; and each guard is mutation-pinned by a test. Refactoring a thrice-reviewed data-loss fix to centralise a documented convention adds risk without changing behaviour. The invariant is recorded in the noteContainer doc comment and the agent-note.
nitNotesManager.swift hasPendingSignInReload / signInReloadTaskThe completed reload task's handle is never cleared on completion (only a cross-document loadNotes clears it), so hasPendingSignInReload reports true for the rest of the document's life and every creation awaits a completed task. Two agents raised variants: rename to hasJoinableSignInReload, or nil the handle on exit guarded by identity.Skipped. Behaviourally harmless — awaiting a completed task's value is the runtime's no-suspension fast path, the closure's captures are released when the body finishes, and the property's own doc comment already says 'joinable'. Renaming would also touch the test file for zero behavioural gain.

Per-file diffs

Click to expand.

prism/Services/NotesManager.swift Modified +162 / -13
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex 4fb45b8..0ad9afc 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -81,6 +81,20 @@ final class NotesManager {     /// `documentNotes` is nil (i.e. no notes exist for this document yet).     private(set) var cachedDocumentIdentifier: DocumentIdentifier? +    /// Blocks from the most recent `loadNotes` call, retained so an iCloud+    /// sign-in reload can relocate against the document the user is actually+    /// reading (T-1811). Held even when the load itself was skipped — that is+    /// precisely the case the reload has to recover from. `MarkdownBlock` is a+    /// value type, so this is a copy-on-write reference to the session's array.+    @ObservationIgnored+    private var cachedBlocks: [MarkdownBlock] = []++    /// The reload started by an iCloud sign-in transition, if one is in flight.+    /// Awaited before any note is created so a note added moments after sign-in+    /// is appended to the reloaded notes rather than racing them.+    @ObservationIgnored+    private var signInReloadTask: Task<Void, Never>?+     // MARK: - Initialization      /// Observation token for iCloud availability changes.@@ -116,7 +130,7 @@ final class NotesManager {     /// Check if iCloud is available for notes storage.     /// - Requirement: 1.6     private func checkiCloudAvailability() {-        iCloudAvailable = FileManager.default.ubiquityIdentityToken != nil+        applyiCloudAvailability(FileManager.default.ubiquityIdentityToken != nil)     }      /// Observe iCloud sign-in/sign-out changes.@@ -133,6 +147,64 @@ final class NotesManager {         }     } +    /// Apply a new iCloud availability value, treating unavailable→available as+    /// a reload boundary for the current document (T-1811).+    ///+    /// A document opened while signed out never read its stored notes —+    /// `loadNotes` caches the identifier and bails. Flipping this flag re-enables+    /// every `guard iCloudAvailable` mutation path, so without a reload the next+    /// note would be appended to a fabricated empty container and saved over+    /// whatever iCloud already holds for the document.+    private func applyiCloudAvailability(_ available: Bool) {+        let wasAvailable = iCloudAvailable+        iCloudAvailable = available+        guard available, !wasAvailable else { return }+        startSignInReload()+    }++    /// Reload the cached document after an iCloud sign-in.+    ///+    /// The reload is bound to the *load* that was current when the sign-in+    /// landed — both the document it was for and the blocks it produced. It is+    /// scheduled, not immediate, so the reader can move to another document+    /// before it runs, and this task and the view-driven load are two+    /// independently scheduled `Task`s with no defined relative order. Running+    /// the old document's load last would rewrite `cachedDocumentIdentifier`/+    /// `cachedBlocks` back to the old document and win the `loadGeneration` race+    /// against the new document's own load, leaving `documentNotes` holding the+    /// previous document's container while the reader is on the new one (T-1811).+    ///+    /// Identity alone is not enough to detect that, because identity can be+    /// revisited: a reload dropped from `signInReloadTask` by a switch to+    /// another document keeps running, and a switch back (A→B→A) re-satisfies an+    /// identifier check while the blocks it captured are two loads out of date.+    /// It would then relocate the reader's notes against outdated content and,+    /// being the newest generation, win. `loadGeneration` is monotonic, so+    /// comparing it retires such a reload for good. The identifier is still+    /// checked because one path — the clipboard→file migration in+    /// `migrateClipboardNotes` — moves the cached identity without starting a+    /// load, so the generation would still match there.+    private func startSignInReload() {+        guard let identifier = cachedDocumentIdentifier else { return }+        let blocks = cachedBlocks+        let generation = loadGeneration+        signInReloadTask = Task { @MainActor [weak self] in+            guard let self,+                  self.loadGeneration == generation,+                  self.cachedDocumentIdentifier == identifier else { return }+            await self.loadNotes(identifier: identifier, blocks: blocks)+        }+    }++    /// Await an in-flight sign-in reload, if any.+    ///+    /// Note creation joins the reload rather than racing it: a note appended to+    /// pre-reload state would be dropped from memory the moment the reload's own+    /// result lands.+    func awaitPendingNotesReload() async {+        await signInReloadTask?.value+    }+     // MARK: - Note Context Resolution      /// Resolves identifier and display name from a `DocumentSource`.@@ -184,10 +256,26 @@ final class NotesManager {      /// Shared loading logic for file, clipboard, and bundled sources.     private func loadNotes(identifier: DocumentIdentifier, blocks: [MarkdownBlock]) async {+        // A switch to a different document retires any sign-in reload still+        // pending for the previous one, so `noteContainer` does not join a+        // reload whose result is already irrelevant (T-1811).+        //+        // This is wait-avoidance, not the correctness backstop: `loadNotes`+        // never checks `Task.isCancelled`, so `cancel()` does not stop the+        // reload — what stops it is its own entry guard in `startSignInReload`+        // plus the generation checks below. Clearing the ivar is the part with+        // an observable effect, and it must run before the cached identity+        // below is overwritten.+        if let cached = cachedDocumentIdentifier, cached != identifier {+            signInReloadTask?.cancel()+            signInReloadTask = nil+        }+         loadGeneration &+= 1         let generation = loadGeneration         cachedDocumentPath = identifier.path         cachedDocumentIdentifier = identifier+        cachedBlocks = blocks          guard iCloudAvailable else { clearNoteState(); return } @@ -574,6 +662,8 @@ final class NotesManager {             contextQuote = quote         } +        var notes = await noteContainer(for: context)+         let sectionHeading = structure.sectionHeading(forBlockId: block.id)         let sectionId = structure.sectionId(forBlockId: block.id)         let headingPath = structure.headingPath(forBlockId: block.id, sourceIndex: sourceIndex)@@ -592,13 +682,6 @@ final class NotesManager {             headingPath: headingPath         ) -        var notes = documentNotes ?? DocumentNotes(-            identifier: context.identifier,-            displayName: context.displayName,-            notes: [],-            createdAt: Date(),-            modifiedAt: Date()-        )         notes.notes.append(note)         notes.modifiedAt = Date() @@ -717,6 +800,7 @@ final class NotesManager {         to parentNote: BlockNote,         context: (identifier: DocumentIdentifier, displayName: String)     ) async {+        var notes = await noteContainer(for: context)         let threadRoot = parentNote.threadId ?? parentNote.noteHash          let note = BlockNote(@@ -731,10 +815,6 @@ final class NotesManager {             threadId: threadRoot         ) -        var notes = documentNotes ?? DocumentNotes(-            identifier: context.identifier,-            displayName: context.displayName-        )         notes.notes.append(note)         notes.modifiedAt = Date() @@ -959,6 +1039,65 @@ final class NotesManager {  extension NotesManager { +    /// The container a newly created note is appended to.+    ///+    /// The single place a creation baseline comes from, because getting it wrong+    /// destroys data: `NotesStore.save` replaces the identifier's file+    /// atomically, so appending to a fabricated empty `DocumentNotes` silently+    /// deletes every note already stored for the document (T-1811). "Nothing is+    /// loaded" and "nothing exists" are not the same state, and only the store+    /// can tell them apart.+    ///+    /// Any in-flight iCloud sign-in reload is joined first; if there is still+    /// nothing loaded — or what is loaded belongs to a different document — the+    /// store is consulted before falling back to a genuinely new container.+    ///+    /// The store read is a suspension point, so this function is reentrant: two+    /// creations that both arrive with nothing loaded both reach it. Neither may+    /// build on a baseline read before the other's note existed, or the later+    /// save deletes the earlier note (same atomic-replace hazard as above). The+    /// re-read after the await is what prevents that — every creation path+    /// publishes its appended container to `documentNotes` synchronously before+    /// its own `persistNotes` await, so a second caller resuming here sees it.+    ///+    /// The identity check is not defensive noise. The save is keyed off the+    /// container's own `identifier`, never off anything the caller passes, so+    /// appending to another document's container writes this note into *that*+    /// document's file. Without the check, anything that can leave+    /// `documentNotes` pointing at a document the reader has left — a load+    /// racing a sign-in reload, a note created before this document was ever+    /// loaded — becomes silent misattribution.+    func noteContainer(+        for context: (identifier: DocumentIdentifier, displayName: String)+    ) async -> DocumentNotes {+        await awaitPendingNotesReload()++        if let notes = documentNotes {+            if notes.identifier == context.identifier { return notes }+            // Loaded state belongs to another document — drop it rather than+            // append to it, so the anchored/orphaned views cannot keep showing+            // the previous document's notes over this one either.+            clearNoteState()+        }++        let stored = await store.load(for: context.identifier)++        // Re-read across that suspension before using `stored`. A concurrent+        // creation for this document may have established the container *and*+        // appended its note while this call was parked in the store; `stored`+        // predates that note, and appending to it would drop it on save. A+        // sign-in reload landing in the same window is equally fresher.+        if let notes = documentNotes, notes.identifier == context.identifier { return notes }++        if let stored {+            documentNotes = stored+            rebuildAnchoredNotes()+            return stored+        }++        return DocumentNotes(identifier: context.identifier, displayName: context.displayName)+    }+     /// Persist notes to the store, logging any errors.     /// Save errors are non-fatal — the app continues operating.     func persistNotes(_ notes: DocumentNotes) async {@@ -1179,9 +1318,19 @@ extension NotesManager {         iCloudObserver != nil     } +    /// Whether a sign-in reload is still joinable by `awaitPendingNotesReload()`.+    ///+    /// The retirement in `loadNotes` has no other observable effect — cancelling+    /// a `Task` that never checks `Task.isCancelled` does not stop it — so this+    /// is what pins it (T-1811).+    var hasPendingSignInReload: Bool { signInReloadTask != nil }+     /// Set iCloud availability for testing.+    ///+    /// Routes through `applyiCloudAvailability` so a test flipping this to+    /// `true` exercises the real sign-in transition, reload included (T-1811).     func setiCloudAvailable(_ available: Bool) {-        iCloudAvailable = available+        applyiCloudAvailability(available)     }      /// Set imported notes for testing.
prism/Services/NotesManager+DocumentLevel.swift Modified +2 / -7
diff --git a/prism/Services/NotesManager+DocumentLevel.swift b/prism/Services/NotesManager+DocumentLevel.swiftindex 27e9567..8298eac 100644--- a/prism/Services/NotesManager+DocumentLevel.swift+++ b/prism/Services/NotesManager+DocumentLevel.swift@@ -57,6 +57,8 @@ extension NotesManager {         content: String,         context: (identifier: DocumentIdentifier, displayName: String)     ) async {+        var notes = await noteContainer(for: context)+         let now = Date()         let note = BlockNote(             id: UUID(),@@ -71,13 +73,6 @@ extension NotesManager {             headingPath: nil         ) -        var notes = documentNotes ?? DocumentNotes(-            identifier: context.identifier,-            displayName: context.displayName,-            notes: [],-            createdAt: now,-            modifiedAt: now-        )         notes.notes.append(note)         notes.modifiedAt = now 
prismTests/NotesManageriCloudSignInTests.swift Added +367 / -0
diff --git a/prismTests/NotesManageriCloudSignInTests.swift b/prismTests/NotesManageriCloudSignInTests.swiftnew file mode 100644index 0000000..142e507--- /dev/null+++ b/prismTests/NotesManageriCloudSignInTests.swift@@ -0,0 +1,367 @@+//+//  NotesManageriCloudSignInTests.swift+//  prismTests+//+//  Regression tests for T-1811.+//++import Foundation+import Testing+@testable import prism++/// Regression tests for T-1811: signing into iCloud while a document is open+/// must not destroy that document's stored notes.+///+/// Opening a document while signed out leaves `documentNotes` nil (the load is+/// skipped). Signing in flips `iCloudAvailable`, which re-enables note creation+/// — and the creation paths used to fall back to a freshly built, empty+/// `DocumentNotes` when `documentNotes` was nil. `NotesStore.save` replaces the+/// identifier's file atomically, so that one-note container wiped every+/// pre-existing note for the document.+@MainActor+struct NotesManageriCloudSignInTests {++    // MARK: - Helpers++    private var url: URL { URL(fileURLWithPath: "/Users/test/project/specs/doc.md") }++    /// A second, unrelated document, for the document-switch races.+    private var otherURL: URL { URL(fileURLWithPath: "/Users/test/project/specs/other.md") }++    private var identifier: DocumentIdentifier {+        DocumentIdentifierResolver().resolve(from: url)+    }++    private var otherIdentifier: DocumentIdentifier {+        DocumentIdentifierResolver().resolve(from: otherURL)+    }++    private func makeNote(blockId: String, content: String) -> BlockNote {+        BlockNote(+            id: UUID(),+            blockId: blockId,+            contextQuote: "Test paragraph",+            content: content,+            status: .active,+            createdAt: Date(),+            modifiedAt: Date()+        )+    }++    /// Preloads the store with one existing note for `block` and returns a+    /// manager that opened the document while iCloud was unavailable.+    private func makeSignedOutManager(+        store: MockNotesStore,+        block: MarkdownBlock,+        existingContent: String = "Existing note"+    ) async -> NotesManager {+        await store.preload(+            DocumentNotes(+                identifier: identifier,+                displayName: "doc.md",+                notes: [makeNote(blockId: block.id, content: existingContent)]+            )+        )++        let manager = NotesManager.makeForTesting(store: store, iCloudAvailable: false)+        await manager.loadNotes(source: .file(url: url), sessionID: UUID(), blocks: [block])+        return manager+    }++    // MARK: - Tests++    @Test("Signing into iCloud reloads the open document's stored notes")+    func signInReloadsNotesForOpenDocument() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let manager = await makeSignedOutManager(store: store, block: block)++        #expect(manager.documentNotes == nil, "Precondition: nothing loaded while signed out")++        manager.setiCloudAvailable(true)+        await manager.awaitPendingNotesReload()++        #expect(manager.documentNotes?.notes.count == 1)+        #expect(manager.anchoredNotes[block.id]?.count == 1)+    }++    @Test("A block note created right after sign-in keeps the stored notes")+    func blockNoteAfterSignInDoesNotOverwriteStoredNotes() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let manager = await makeSignedOutManager(store: store, block: block)++        manager.setiCloudAvailable(true)+        await manager.createNote(+            content: "New note",+            for: block,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [block]),+            source: .file(url: url),+            sessionID: UUID()+        )++        let stored = await store.storedNotes[identifier.path]+        #expect(stored?.notes.count == 2, "The pre-existing note must survive the new one")+        #expect(stored?.notes.contains { $0.content == "Existing note" } == true)+        #expect(stored?.notes.contains { $0.content == "New note" } == true)+    }++    @Test("A document-level note created right after sign-in keeps the stored notes")+    func documentNoteAfterSignInDoesNotOverwriteStoredNotes() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let manager = await makeSignedOutManager(store: store, block: block)++        manager.setiCloudAvailable(true)+        await manager.createDocumentNote(content: "New document note")++        let stored = await store.storedNotes[identifier.path]+        #expect(stored?.notes.count == 2, "The pre-existing note must survive the new one")+        #expect(stored?.notes.contains { $0.content == "Existing note" } == true)+    }++    @Test("Creating a note without a prior load hydrates from the store first")+    func noteCreatedWithoutPriorLoadHydratesFromStore() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        await store.preload(+            DocumentNotes(+                identifier: identifier,+                displayName: "doc.md",+                notes: [makeNote(blockId: block.id, content: "Existing note")]+            )+        )++        // No loadNotes call at all — the manager has never seen this document.+        let manager = NotesManager.makeForTesting(store: store)+        await manager.createNote(+            content: "New note",+            for: block,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [block]),+            source: .file(url: url),+            sessionID: UUID()+        )++        let stored = await store.storedNotes[identifier.path]+        #expect(stored?.notes.count == 2, "The pre-existing note must survive the new one")+    }++    // MARK: - Document switch races++    @Test("Switching documents during a sign-in reload leaves the new document loaded")+    func documentSwitchDuringSignInReloadDoesNotRebindToOldDocument() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let otherBlock = MarkdownBlock.paragraph(markdown: "Other paragraph")+        let manager = await makeSignedOutManager(store: store, block: block)++        await store.preload(+            DocumentNotes(+                identifier: otherIdentifier,+                displayName: "other.md",+                notes: [makeNote(blockId: otherBlock.id, content: "Other note")]+            )+        )++        // Sign in (schedules a reload bound to doc.md), then immediately open+        // other.md — the reload must not rewrite the manager back onto doc.md.+        manager.setiCloudAvailable(true)+        await manager.loadNotes(source: .file(url: otherURL), sessionID: UUID(), blocks: [otherBlock])+        await manager.awaitPendingNotesReload()++        #expect(manager.cachedDocumentIdentifier == otherIdentifier)+        #expect(manager.documentNotes?.identifier == otherIdentifier)+        #expect(manager.documentNotes?.notes.contains { $0.content == "Other note" } == true)+    }++    @Test("A note created after a mid-reload document switch is saved to the open document")+    func noteAfterDocumentSwitchDuringSignInReloadSavesToOpenDocument() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let otherBlock = MarkdownBlock.paragraph(markdown: "Other paragraph")+        let manager = await makeSignedOutManager(store: store, block: block)++        await store.preload(+            DocumentNotes(+                identifier: otherIdentifier,+                displayName: "other.md",+                notes: [makeNote(blockId: otherBlock.id, content: "Other note")]+            )+        )++        manager.setiCloudAvailable(true)+        await manager.loadNotes(source: .file(url: otherURL), sessionID: UUID(), blocks: [otherBlock])+        await manager.createNote(+            content: "New note",+            for: otherBlock,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [otherBlock]),+            source: .file(url: otherURL),+            sessionID: UUID()+        )++        let storedOther = await store.storedNotes[otherIdentifier.path]+        #expect(storedOther?.notes.count == 2, "The note belongs to the document being read")+        #expect(storedOther?.notes.contains { $0.content == "New note" } == true)++        let storedOriginal = await store.storedNotes[identifier.path]+        #expect(storedOriginal?.notes.count == 1, "The document left behind must be untouched")+        #expect(storedOriginal?.notes.contains { $0.content == "New note" } == false)+    }++    @Test("A note is never appended to a container belonging to another document")+    func noteIsNotAppendedToAnotherDocumentsContainer() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let otherBlock = MarkdownBlock.paragraph(markdown: "Other paragraph")++        await store.preload(+            DocumentNotes(+                identifier: identifier,+                displayName: "doc.md",+                notes: [makeNote(blockId: block.id, content: "Existing note")]+            )+        )+        await store.preload(+            DocumentNotes(+                identifier: otherIdentifier,+                displayName: "other.md",+                notes: [makeNote(blockId: otherBlock.id, content: "Other note")]+            )+        )++        // doc.md is loaded, so `documentNotes` holds its container. Creating a+        // note for other.md must not use that container as the baseline — the+        // save is keyed off the container's own identifier.+        let manager = NotesManager.makeForTesting(store: store)+        await manager.loadNotes(source: .file(url: url), sessionID: UUID(), blocks: [block])+        #expect(manager.documentNotes?.identifier == identifier, "Precondition: doc.md is loaded")++        await manager.createNote(+            content: "New note",+            for: otherBlock,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [otherBlock]),+            source: .file(url: otherURL),+            sessionID: UUID()+        )++        let storedOther = await store.storedNotes[otherIdentifier.path]+        #expect(storedOther?.notes.count == 2)+        #expect(storedOther?.notes.contains { $0.content == "New note" } == true)++        let storedOriginal = await store.storedNotes[identifier.path]+        #expect(storedOriginal?.notes.count == 1, "doc.md must not gain the other document's note")+    }++    @Test("Switching documents retires the pending sign-in reload rather than leaving it joinable")+    func documentSwitchRetiresPendingSignInReload() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let otherBlock = MarkdownBlock.paragraph(markdown: "Other paragraph")+        let manager = await makeSignedOutManager(store: store, block: block)++        manager.setiCloudAvailable(true)+        #expect(manager.hasPendingSignInReload, "Precondition: sign-in scheduled a reload for doc.md")++        // `loadNotes` retires the reload in its prologue, before its own first+        // suspension — so the handle is gone by the time the switch completes.+        // Nothing else clears it: cancelling a task that never checks+        // `Task.isCancelled` leaves it running to its own entry guard, and a+        // finished task is still joinable. Without the retirement, every+        // subsequent note creation would join a reload whose result has already+        // been superseded.+        await manager.loadNotes(source: .file(url: otherURL), sessionID: UUID(), blocks: [otherBlock])++        #expect(manager.hasPendingSignInReload == false)+    }++    @Test("A reload orphaned by a document switch cannot reload stale blocks when the reader returns")+    func returningToTheOriginalDocumentDoesNotReviveTheOrphanedReload() async {+        let store = MockNotesStore()+        // The stored note is anchored to `block`, so it relocates cleanly+        // against `block` and orphans against `staleBlock` — that difference is+        // what distinguishes the two reload inputs.+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        let staleBlock = MarkdownBlock.paragraph(markdown: "A completely different paragraph")+        let otherBlock = MarkdownBlock.paragraph(markdown: "Other paragraph")++        await store.preload(+            DocumentNotes(+                identifier: identifier,+                displayName: "doc.md",+                notes: [makeNote(blockId: block.id, content: "Existing note")]+            )+        )++        // doc.md was opened while signed out against the stale blocks.+        let manager = NotesManager.makeForTesting(store: store, iCloudAvailable: false)+        await manager.loadNotes(source: .file(url: url), sessionID: UUID(), blocks: [staleBlock])++        // Sign in, then straight back out. The reload for doc.md is scheduled+        // with the stale blocks, and signing out again leaves it pending: the+        // switches below take the `guard iCloudAvailable` early return, which+        // never suspends, so the orphan cannot run its entry guard while+        // other.md is the open document. That is exactly the A→B→A window —+        // main-actor task ordering hides it whenever the switches suspend.+        manager.setiCloudAvailable(true)+        manager.setiCloudAvailable(false)++        await manager.loadNotes(source: .file(url: otherURL), sessionID: UUID(), blocks: [otherBlock])+        await manager.loadNotes(source: .file(url: url), sessionID: UUID(), blocks: [block])++        // Let the orphan run. Its identifier check passes again — doc.md is the+        // open document once more — so only the generation it was scheduled at+        // can tell it that its blocks are two loads out of date.+        await Task.yield()+        await Task.yield()++        // Sign in for real. The reload must relocate against the blocks doc.md+        // was re-opened with, not the ones the orphan would have restored.+        manager.setiCloudAvailable(true)+        await manager.awaitPendingNotesReload()++        #expect(manager.anchoredNotes[block.id]?.count == 1, "The note anchors against the current blocks")+        #expect(manager.orphanedNotes.isEmpty, "Stale blocks would have orphaned it")+    }++    // MARK: - Concurrent creation++    @Test("Two notes created at once with nothing loaded both survive")+    func concurrentCreationsWithNothingLoadedKeepBothNotes() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test paragraph")+        await store.preload(+            DocumentNotes(+                identifier: identifier,+                displayName: "doc.md",+                notes: [makeNote(blockId: block.id, content: "Existing note")]+            )+        )++        // Nothing loaded, so both creations reach `noteContainer`'s store read+        // and suspend there. The second must build on what the first left+        // behind, not on the baseline it read before the first note existed —+        // `NotesStore.save` replaces the document's file atomically.+        let manager = NotesManager.makeForTesting(store: store)+        let structure = MarkdownSectionBuilder.build(from: [block])++        async let first: Void = manager.createNote(+            content: "First note", for: block, sourceIndex: 0,+            in: structure, source: .file(url: url), sessionID: UUID()+        )+        async let second: Void = manager.createNote(+            content: "Second note", for: block, sourceIndex: 0,+            in: structure, source: .file(url: url), sessionID: UUID()+        )+        _ = await (first, second)++        let stored = await store.storedNotes[identifier.path]+        #expect(stored?.notes.count == 3, "Neither new note may drop the other")+        #expect(stored?.notes.contains { $0.content == "Existing note" } == true)+        #expect(stored?.notes.contains { $0.content == "First note" } == true)+        #expect(stored?.notes.contains { $0.content == "Second note" } == true)+    }+}
docs/agent-notes/notes-system.md Modified +25 / -0
diff --git a/docs/agent-notes/notes-system.md b/docs/agent-notes/notes-system.mdindex 6d6c5ba..2678066 100644--- a/docs/agent-notes/notes-system.md+++ b/docs/agent-notes/notes-system.md@@ -102,6 +102,31 @@ Session restoration for clipboard is best-effort:  `NotesManager` registers a `NotificationCenter` observer for `.NSUbiquityIdentityDidChange` in `init`. The `deinit` removes it via `NotificationCenter.default.removeObserver(_:)`. The `iCloudObserver` property is `nonisolated(unsafe)` because `deinit` is nonisolated in Swift but the access is safe (written in `init`, read in `deinit`, serialized by object lifetime). This follows the same pattern as `FileChangeObserver.deinit`. +## iCloud Sign-In Reload Boundary (T-1811)++`iCloudAvailable` is not just a UI-enablement flag — it is a precondition on whether the current document's notes have been *read*. `loadNotes` caches the identifier and bails when iCloud is unavailable, so a document opened while signed out has an identifier but no notes, and flipping the flag re-enables every `guard iCloudAvailable` mutation path.++Two things keep that from destroying data, and both matter:++- The identity observer routes through `applyiCloudAvailability(_:)`, which treats unavailable→available as a reload boundary and starts `signInReloadTask` for the cached document. `loadNotes` therefore retains `cachedBlocks` even on the path where it loads nothing — that skipped load is exactly what the reload has to recover from, and relocation needs the blocks.+- `noteContainer(for:)` is the single source of a creation baseline for all three creation paths (`createAndPersistNote`, `createReply`, `handleDocumentNoteCreation`). It joins any in-flight sign-in reload, then reads the store when `documentNotes` is nil **or belongs to a different document**. Never reintroduce a `documentNotes ?? DocumentNotes(...)` fallback at a write site: `NotesStore.save` replaces the identifier's file atomically, so a fabricated empty container is a silent delete of every stored note. "Nothing is loaded" and "nothing exists" are different states and only the store can tell them apart.++The DEBUG `setiCloudAvailable(_:)` routes through the same entry point, so a test flipping it to `true` exercises the real transition; `awaitPendingNotesReload()` is the join point.++### The reload is bound to a load, not just to a moment++The reload is *scheduled*, so the reader can leave the document before it runs, and `signInReloadTask` and the view-driven `.task(id: session.parseRevision)` load are two independently scheduled top-level `Task`s with no defined relative order. `loadNotes`'s own generation guard does not cover this: its prologue rewrites `cachedDocumentPath`/`cachedDocumentIdentifier`/`cachedBlocks` and bumps `loadGeneration` *before* its first `await`, so a stale reload whose prologue runs last wins the race and leaves the manager pointing at the document the reader just left. One guard closes that, a second avoids a pointless wait, and a third makes the write safe regardless:++1. **`startSignInReload`'s entry guard** — the task captures `(identifier, blocks, loadGeneration)` at schedule time and abandons itself unless both the identifier and the generation still hold at entry. Identity alone is not enough, because identity can be revisited: a reload dropped from `signInReloadTask` by a switch to another document keeps running (see guard 2), and a switch back (A→B→A) re-satisfies an identifier check while the captured blocks are two loads stale — it would then relocate against outdated content and, being the newest generation, win. `loadGeneration` is monotonic, so any intervening load retires the reload for good. The identifier is still checked because `migrateClipboardNotes` moves the cached identity without starting a load, leaving the generation unchanged.+2. **`loadNotes`'s retirement of `signInReloadTask`** — wait-avoidance, *not* a correctness backstop. `loadNotes` never checks `Task.isCancelled`, so `cancel()` does not stop the reload; the only observable effect is clearing the ivar so `noteContainer` does not join a reload whose result is already irrelevant. What actually stops that reload is guard 1.+3. **`noteContainer(for:)`'s identity check** — `documentNotes.identifier == context.identifier`. This is the one that matters for data safety regardless of how a mismatch arose, because `NotesStore.save` keys the write off the *container's* identifier, not off anything the caller passes — appending to another document's container writes the note into that document's file and saves it back over the top. On a mismatch it calls `clearNoteState()` and re-reads from the store. It also covers the plain no-race case of creating a note for a document that was never loaded while a different one is loaded.++Regression coverage is in `NotesManageriCloudSignInTests`, one test per guard: `documentSwitchDuringSignInReloadDoesNotRebindToOldDocument` and `returningToTheOriginalDocumentDoesNotReviveTheOrphanedReload` for guard 1 (identity and generation halves respectively), `documentSwitchRetiresPendingSignInReload` for guard 2's ivar clearing, and `noteIsNotAppendedToAnotherDocumentsContainer` for guard 3. Each fails if its guard is removed. The A→B→A test has to sign out between the switches: with iCloud available, `loadNotes` suspends in the store and main-actor task ordering lets the orphan reach its entry guard while the *other* document is still current, which hides the window.++### `noteContainer(for:)` is reentrant++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`.+ ## 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.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5224d5a..1117594 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Signing into iCloud with a document open no longer costs you the notes you already had on it (T-1811). Opening a document while signed out leaves its notes unread — there is nowhere to read them from — and signing in afterwards made notes available again without ever going back for them, so the first note you added was saved as if it were the only one the document had ever had, replacing every note already stored for it. Signing in now reloads the open document's notes straight away, so they reappear without closing and reopening the file, and a note added in the moment before that finishes waits for it rather than racing it. Saving a note can no longer replace notes it has not read, on any path: if nothing has been loaded for a document, what is already stored is read first and the new note is added to it — and two notes added in the same moment now both survive, rather than the second saving over the first. A note is also always saved to the document you are actually reading — opening a different file in the moment after signing in leaves the first one alone, and the notes you add then belong to the file in front of you rather than to the one you left. - Choosing where to go while a document reloads now takes you there (T-1975). If a file changed on disk — or a URL document was refreshed — while you had it open, and during the moment the app spends preparing the new version you picked a table-of-contents entry, tapped a note, followed a link to a heading, or stepped to a search match, the reloaded document appeared at your saved reading position instead. What you chose was handed to the copy still on screen, which was about to be replaced, so nothing was left to say where you had asked to go and restoring your place won — and on a large document, where preparing the new version takes longest, that window is at its widest. The document on screen is now treated as superseded from the moment a reload starts rather than from the moment the new version is ready, so anything you choose in between is held for the version that is coming and takes precedence over your saved place, exactly as it already did when you chose a moment later. This holds when a file changes twice in quick succession, so a second reload beginning before the first has finished preparing still takes you where you asked rather than back to your saved place. Reloads you did not navigate during still return you to where you were reading, and once the reloaded document has taken you where you asked, the next reload restores your place normally. Scrolling while a reload prepares still counts too, however you do it — dragging, a trackpad or wheel, **Page Up** and **Page Down**, or **Scroll to Top** and **Scroll to Bottom** — because the document stays in front of you and stays scrollable the whole time: the place you scroll to is the place you are returned to. - Changing the reading font or text size no longer moves you somewhere else in the document (T-1965). Both settings already applied without reloading, but they reflow the whole document and nothing put you back afterwards: raising **Larger Text** to an accessibility size makes every block roughly three times as tall, so the text you were reading slid off the bottom of the screen and left you looking at something you had already been through. The app then recorded that new spot as where you were reading, so closing and reopening the document returned you to it as well. Your place is now kept across the change — including how far into a paragraph you were, so the same words stay in front of you rather than merely the same paragraph starting at the top — and a place you were never reading can no longer be saved while the document settles. Jumping somewhere while the change is settling wins: a table-of-contents entry, a link, a note, or a search match all take you where you asked, and the re-anchoring steps aside. Collapsing the section you were reading during the change leaves you at its heading rather than at content that is no longer shown. - A document that goes blank because its rendering process stopped now restores itself (T-1943). The app has always been able to recover from this — it reloads the document and puts back your theme, your reading position, your note markers, and any active search highlights — but nothing was ever watching for the rendering process to stop, so the recovery never actually ran. A large or image-heavy document whose renderer was shut down under memory pressure therefore showed an empty page, with no error and no way back except closing the file and opening it again. The app now watches for it and recovers on the spot. An ordinary failure to load — a link that goes nowhere, an image that cannot be fetched — is told apart from a stopped renderer, so it neither causes a needless reload nor stops the app watching for a real one afterwards. The recovery also covers its own failure: if the reload it starts cannot itself load the document, that counts as the recovery failing and is tried again, instead of leaving the page blank with nothing running. A reload that neither succeeds nor fails — one that simply never finishes — is covered too: it is given a generous time limit, well beyond what even a large document takes to appear, and is then treated as a failed recovery and tried again rather than leaving the page blank indefinitely. If reloading repeatedly fails to bring the document back, the app stops retrying rather than reloading over and over — and says so, with a banner offering to reload. Taking that reload also restores the document's ability to recover on its own again, so giving up is never permanent while the file stays open.

Things to double-check

Real-device sign-in transition.

All coverage drives the transition through the DEBUG seam. The production trigger is .NSUbiquityIdentityDidChangecheckiCloudAvailability(); worth one manual pass on a device (sign out, open a noted document, sign back in) to confirm the notification actually fires the reload path with real iCloud latencies.

Future mutation paths.

The agent-note bans the ?? container fallback at write sites, but nothing mechanical enforces it. Any new creation-like path must go through noteContainer(for:) and publish documentNotes synchronously before its first await — that invariant is what makes the reentrancy re-read sound.

Direct account switch without a nil intermediate.

applyiCloudAvailability collapses the ubiquity token to a Bool, so an account change that never passes through a nil token would not cross the unavailable→available boundary and would not trigger the reload. Apple's flows route account switches through sign-out (nil → token), which is handled; not actionable today, but worth knowing if account-switch behaviour ever changes.

Pre-existing: clipboard migration saves unread state over the target file.

migrateNotes(fromClipboardSession:toFileURL:) relabels an already-loaded clipboard container onto the target file's identifier and saves it, without first reading what that identifier already holds. Outside this diff's scope (the user is deliberately overwriting that file's content), but it is the one remaining save path that does not consult the store first.