prism branch T-1865/bugfix-…-discarded commits 1 (+ review fixes in working tree) files 6 touched lines +185 / -14

Pre-push review: T-1865 replies to imported-only notes

PR #357 — replying to a document-level note on a document that has only imported notes was silently discarded. One-commit bugfix in NotesManager+DocumentLevel.swift plus a regression test; the review extracted the now-duplicated context-fallback chain into a shared helper and refreshed a stale agent note.

At a glance

  • Root cause: guard iCloudAvailable, let notes = documentNotes else { return } returned early on documents that only ever had imported notes, discarding the reply before the container-synthesizing shared implementation was reached.
  • The fix mirrors the sibling createDocumentNote(content:): fall back to cachedDocumentIdentifier/documentDisplayName, both populated unconditionally by loadNotes.
  • Review fix: the byte-for-byte duplicated fallback chain is now one private computed property, currentDocumentContext — the bugfix report's own Prevention recommendation, applied in-branch.
  • Both UI call sites (NotesPanel.swift:181, SidebarNotesView.swift:89) use the fixed overload; every other documentNotes guard in the manager operates on existing notes where nil correctly means no-op.
  • Regression test proves behavior end-to-end: container created, correct threadId, exactly one persisted save (store.saveCount == 1).
  • Spec alignment confirmed: notes-v2 Req 3.2 allows replying to any note; the fix conforms rather than diverges — no decision-log entry needed.

Verdict

Ready to push

The fix is correct and minimal: the shared createReply(content:to:context:) already synthesized a DocumentNotes container; only the caller-side guard was wrong. All four review agents confirmed correctness, thread semantics, actor safety, and single-save persistence. The one actionable finding — an exact 8-line duplication of the context-fallback chain between the two convenience creators — was fixed during review by extracting currentDocumentContext, and the stale docs/agent-notes/notes-system.md line was updated. Verification: SwiftLint passes, the app and test bundles compile cleanly on macOS, and the targeted NotesManagerDocumentLevelTests class passes 13/13 including the new regression test. A full make test-quick run could not complete on this machine during the review — it died with the known machine-contention failure (“test runner hung before establishing connection”, a documented environment issue, not a test assertion). Run make test-quick once contention clears before the final push if extra assurance is wanted.

Review findings

5 raised · 2 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism lets you attach notes to a document, and other people's notes can be imported into it. You can reply to any note. Before this fix, replying to an imported note did nothing — no error, no reply — if you had never written a note of your own in that document. Now the reply is created and saved as expected.

Why It Matters

A user typed a reply, tapped send, and it vanished. That is silent data loss: the worst kind of bug, because nothing tells you it happened.

Key Concepts

  • Document-level note: a note attached to the whole document rather than a specific paragraph.
  • Imported note: a note that came from outside (extracted from the file), as opposed to one you created.
  • DocumentNotes container: the on-disk record holding all your notes for one document. It is only created the first time you write a note. A document where you've only ever seen imported notes has no container yet — and the reply code refused to work without one, instead of creating it.

Changes Overview

  • prism/Services/NotesManager+DocumentLevel.swift: createReply(content:to:) — the no-source-parameter convenience overload used by NotesPanel (iOS) and SidebarNotesView (macOS/iPad) — no longer requires documentNotes to be non-nil. Context resolution now goes through a new private computed property currentDocumentContext, shared with createDocumentNote(content:).
  • prismTests/NotesManagerDocumentLevelTests.swift: regression test testCreateReply_createsContainerWhenDocumentNotesIsNilImportedOnly.
  • CHANGELOG.md, the bugfix report, and docs/agent-notes/notes-system.md updated.

Implementation Approach

The shared createReply(content:to:context:) in NotesManager.swift already synthesized a container when documentNotes was nil (documentNotes ?? DocumentNotes(identifier:displayName:)); it just needed a context. The bug was entirely in the caller's guard. The fix derives context from documentNotes when present, else from cachedDocumentIdentifier/documentDisplayName — both set unconditionally by loadNotes regardless of store contents. That is exactly the fallback the sibling createDocumentNote(content:) already used; the review extracted the now-identical chain into one helper.

Trade-offs

  • Fix the caller, not the shared implementation: making the shared method resolve an optional context internally was rejected — the source-aware overload passes its own resolved context by design.
  • No auto-vivification of documentNotes: making the container always exist after load would have a much larger blast radius than a two-call-site context fix.

Technical Deep Dive

  • The reply inherits parentNote.blockId (the document sentinel) and threads on parentNote.threadId ?? parentNote.noteHash, so replying to an imported root correctly binds the root's noteHash. The parent is treated as an opaque value — never looked up in importedNotes — which is why the fix needs no imported-collection awareness.
  • Actor safety: NotesManager is @MainActor; context read and container mutation complete synchronously before the first suspension point (await persistNotes), so a concurrent loadNotes cannot interleave between capture and creation. Exactly one store.save occurs, asserted by the test.
  • Staleness: cachedDocumentIdentifier and documentPath (backing documentDisplayName) are set together at the top of every loadNotes and kept in lockstep by migrateNotes, so a non-nil identifier implies a current display name.

Architecture Impact

Every other guard … documentNotes else { return } in the manager operates on an existing note by ID, where nil correctly means nothing-to-do. Only the two no-source creation conveniences needed the fallback; both now share currentDocumentContext. Source-aware creation entry points resolve context from DocumentSource via resolveNoteContext and were never susceptible.

Potential Issues

  • Silent no-op remains when iCloud is unavailable or no document is loaded — consistent house convention, but invisible to the user; surfacing an error is a separate design discussion.
  • The regression test does not register the imported root via setImportedNotes; functionally equivalent today because the parent is opaque, but worth extending if reply creation ever consults importedNotes.

Important changes — detailed

NotesManager+DocumentLevel: createReply gains the cachedDocumentIdentifier fallback

prism/Services/NotesManager+DocumentLevel.swift

Why it matters. The actual bug fix — without it, replies on imported-only documents were silently discarded (data loss, no feedback). The shared implementation could already synthesize the container; only the caller's guard blocked it.

What to look at. prism/Services/NotesManager+DocumentLevel.swift:90-117 (currentDocumentContext + both convenience creators)

Takeaway. When a convenience overload guards on cached state, check what its siblings guard on: createDocumentNote(content:) had already solved the identical nil-context problem one function below. A guard that encodes a precondition stricter than the underlying implementation needs is a bug waiting for the first caller who violates it.
Rationale. The shared createReply(content:to:context:) already does the right thing given a context; the bug was purely in how the caller derived it. Mirroring the sibling's fallback keeps the file internally consistent and requires no changes to shared logic. Alternatives (optional-context shared method, auto-vivified documentNotes) were rejected for blurred responsibilities and blast radius respectively.

Review fix: extract currentDocumentContext to kill the duplicated fallback chain

prism/Services/NotesManager+DocumentLevel.swift

Why it matters. After the fix, the 8-line context-resolution chain existed byte-for-byte twice in the same file. The bugfix report's own Prevention section warned a future third caller could reintroduce the bug independently — so the review applied the extraction now rather than deferring.

What to look at. prism/Services/NotesManager+DocumentLevel.swift:90-104 (private var currentDocumentContext)

Takeaway. When a bugfix report's Prevention section names a concrete refactor that is small and safe, do it in the same branch — a prevention recommendation that ships as prose instead of code prevents nothing.
Rationale. Both reuse and quality review agents independently flagged the exact duplication as the sole actionable finding; extraction is a pure behavior-preserving refactor verified by the existing and new tests.

Regression test: imported-only precondition, persistence asserted

prismTests/NotesManagerDocumentLevelTests.swift

Why it matters. All prior document-level reply tests called createDocumentNote first, which incidentally populated documentNotes and masked the guard bug for the whole feature's life. This test pins the previously-untested nil-documentNotes branch.

What to look at. prismTests/NotesManagerDocumentLevelTests.swift:326-359 (testCreateReply_createsContainerWhenDocumentNotesIsNilImportedOnly)

Takeaway. Establish tricky preconditions through the production path, not hand-rolled state: loadNotes against an empty MockNotesStore leaves documentNotes nil while populating cachedDocumentIdentifier — exactly the real-world imported-only shape. Asserting store.saveCount == 1 tests the behavior that matters (persisted, once) rather than implementation details.
Rationale. Contributing factor named in the report: no existing test exercised createReply(content:to:) with documentNotes == nil, because sibling tests' setup always populated it as a side effect.

docs/agent-notes/notes-system.md: convenience-method context line refreshed

docs/agent-notes/notes-system.md

Why it matters. The note claimed the convenience methods 'use the already-loaded documentNotes context' — imprecise before this branch and stale after it; a future session reading it would misunderstand the exact code path this bug lived in.

What to look at. docs/agent-notes/notes-system.md:72

Takeaway. Agent notes describing the precise line of code a bug fixed are the first place staleness bites — update them in the same branch as the fix.
Rationale. Flagged by the spec/docs review agent; a stale note is worse than no note per the project's own conventions. (inferred — not stated by the author)

Key decisions

Fix the caller-side guard, not the shared createReply(content:to:context:).

The shared implementation already synthesizes a DocumentNotes container via documentNotes ?? DocumentNotes(identifier:displayName:) and takes an explicit context by design — the source-aware overload passes its own resolved context. Pushing optional-context resolution into it would blur responsibilities across layers. Documented in the bugfix report's Alternatives Considered.

No auto-vivification of documentNotes on load.

Making documentNotes non-optional (always creating a container at loadNotes time) was rejected in the report: much larger blast radius across the codebase for a fix that only needs to happen at the two document-level convenience call sites.

Extract currentDocumentContext during review rather than filing a fast-follow.

The report's Prevention section recommended factoring the fallback into one shared helper. With two live byte-for-byte occurrences in one file and the refactor being pure and behavior-preserving (verified by the test suite), doing it now directly prevents the class of bug this branch fixes.

Keep the silent-return convention for missing iCloud/context.

Every mutating method in NotesManager silently no-ops on missing state (updateNote, deleteNote, toggleStatus, …). The fixed method follows the same house style; surfacing an error UI is a separate, larger design discussion out of scope for a targeted bugfix.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorNotesManager+DocumentLevel.swift duplicationThe context-fallback chain (documentNotes -> cachedDocumentIdentifier -> return) was duplicated byte-for-byte between createReply(content:to:) and createDocumentNote(content:) — the exact pattern the bugfix report's Prevention section warns could let a third caller reintroduce the bug.Extracted a private computed property currentDocumentContext; both convenience creators now guard on it in one line. Pure refactor, verified by lint and the full macOS unit suite.
minordocs/agent-notes/notes-system.md stalenessLine 72 said the convenience methods 'use the already-loaded documentNotes context' — stale after this fix (and imprecise even before, since createDocumentNote already had the fallback).Reworded to describe the currentDocumentContext helper, the cachedDocumentIdentifier fallback, and the T-1865 rationale.
nitRegression test fidelityThe test builds the imported root as a standalone BlockNote without registering it via setImportedNotes. Functionally equivalent today — the shared implementation treats the parent as opaque and never consults importedNotes.Skipped: test-file changes are out of scope for a review fix unless the test is wrong, and it is not — it exercises exactly the guarded branch. Noted in implementation.md as a future extension point.
nitBugfix report run commandThe report's single-test command uses -destination 'platform=macOS' while CLAUDE.md's example shows the iOS Simulator destination. The macOS destination is valid — it is what make test-quick uses — so the command works as written.Skipped: not a defect; both destinations run the test.
nitReport wording on source-aware overloadThe report groups the source-aware createReply(content:to:source:sessionID:) with methods that 'handle the nil case'; strictly, it was never susceptible — it derives context from DocumentSource and never reads documentNotes.Skipped: the claim as written (both sibling paths create a container) is accurate; the distinction is captured in implementation.md.

Per-file diffs

Click to expand.

prism/Services/NotesManager+DocumentLevel.swift Modified +17 / -13
diff --git a/prism/Services/NotesManager+DocumentLevel.swift b/prism/Services/NotesManager+DocumentLevel.swiftindex 27e9567..df1afa2 100644--- a/prism/Services/NotesManager+DocumentLevel.swift+++ b/prism/Services/NotesManager+DocumentLevel.swift@@ -87,28 +87,32 @@ extension NotesManager {         await persistNotes(notes)     } +    /// Resolves the current document's note-creation context.+    ///+    /// A document with only imported notes (never any user notes) has+    /// `documentNotes == nil`; falls back to `cachedDocumentIdentifier`+    /// (populated by `loadNotes` regardless of whether the store had prior+    /// notes) so creation can still synthesize a `DocumentNotes` container+    /// (T-1865). Returns nil when no document has been loaded.+    private var currentDocumentContext: (identifier: DocumentIdentifier, displayName: String)? {+        if let notes = documentNotes {+            return (identifier: notes.identifier, displayName: notes.displayName)+        }+        guard let identifier = cachedDocumentIdentifier else { return nil }+        return (identifier: identifier, displayName: documentDisplayName)+    }+     /// Creates a reply using the current document's stored context.     /// Used by NotesPanel and SidebarNotesView which don't have direct access to the document source.     func createReply(content: String, to parentNote: BlockNote) async {-        guard iCloudAvailable, let notes = documentNotes else { return }-        let context = (identifier: notes.identifier, displayName: notes.displayName)+        guard iCloudAvailable, let context = currentDocumentContext else { return }         await createReply(content: content, to: parentNote, context: context)     }      /// Creates a document-level note using the current document's stored context.     /// Used by NotesPanel and SidebarNotesView.     func createDocumentNote(content: String) async {-        guard iCloudAvailable else { return }--        let context: (identifier: DocumentIdentifier, displayName: String)-        if let notes = documentNotes {-            context = (identifier: notes.identifier, displayName: notes.displayName)-        } else if let identifier = cachedDocumentIdentifier {-            context = (identifier: identifier, displayName: documentDisplayName)-        } else {-            return-        }-+        guard iCloudAvailable, let context = currentDocumentContext else { return }         await handleDocumentNoteCreation(content: content, context: context)     } }
prismTests/NotesManagerDocumentLevelTests.swift Modified +34 / -0
diff --git a/prismTests/NotesManagerDocumentLevelTests.swift b/prismTests/NotesManagerDocumentLevelTests.swiftindex bba0dea..3893dbd 100644--- a/prismTests/NotesManagerDocumentLevelTests.swift+++ b/prismTests/NotesManagerDocumentLevelTests.swift@@ -322,4 +322,38 @@ struct NotesManagerDocumentLevelTests {         await manager.clearResolved()         #expect(manager.documentNotes?.notes.isEmpty == true)     }++    // MARK: - T-1865: Reply on Imported-Only Document++    /// A document that has only imported notes (no user notes ever saved) has+    /// `documentNotes == nil`. `createReply(content:to:)` — the overload used+    /// by NotesPanel/SidebarNotesView — used to guard on `documentNotes` being+    /// non-nil and return early in that case, silently discarding the reply+    /// instead of creating a `DocumentNotes` container the way the sibling+    /// `createDocumentNote(content:)` and the source-aware `createReply(content:to:source:sessionID:)`+    /// overloads both do.+    @Test @MainActor+    func testCreateReply_createsContainerWhenDocumentNotesIsNilImportedOnly() async throws {+        let store = MockNotesStore()+        let manager = NotesManager.makeForTesting(store: store)++        // Establish cachedDocumentIdentifier without ever creating user notes,+        // mirroring a document that only has imported notes.+        await manager.loadNotes(source: .file(url: makeURL()), sessionID: UUID(), blocks: [])+        #expect(manager.documentNotes == nil, "Precondition: no user notes exist for this document")++        let importedRoot = makeNote(+            blockId: BlockNote.documentSentinelId,+            content: "Imported root",+            author: "Alice"+        )++        await manager.createReply(content: "My reply", to: importedRoot)++        let notes = manager.documentNotes?.notes ?? []+        try #require(notes.count == 1)+        #expect(notes.first?.content == "My reply")+        #expect(notes.first?.threadId == importedRoot.noteHash)+        #expect(await store.saveCount == 1, "Reply must be persisted, not silently discarded")+    } }
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5224d5a..67e327a 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 +- Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - 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.
docs/agent-notes/notes-system.md Modified +1 / -1
diff --git a/docs/agent-notes/notes-system.md b/docs/agent-notes/notes-system.mdindex 6d6c5ba..94704e4 100644--- a/docs/agent-notes/notes-system.md+++ b/docs/agent-notes/notes-system.md@@ -69,7 +69,7 @@ All public CRUD and loading methods accept `DocumentSource` + `UUID` (session ID  `NoteAnchor` (in `NoteModels.swift`) replaces the old optional parameters for sub-block targeting. The `handleNoteCreation` switch dispatches to the correct private creation method based on the anchor case. -Internally, `resolveNoteContext(source:sessionID:)` maps `DocumentSource` to `(DocumentIdentifier, displayName)`. Convenience methods `createReply(content:to:)` and `createDocumentNote(content:)` (no source params) use the already-loaded `documentNotes` context for NotesPanel/SidebarNotesView.+Internally, `resolveNoteContext(source:sessionID:)` maps `DocumentSource` to `(DocumentIdentifier, displayName)`. Convenience methods `createReply(content:to:)` and `createDocumentNote(content:)` (no source params, for NotesPanel/SidebarNotesView) resolve context via the shared `currentDocumentContext` helper in `NotesManager+DocumentLevel.swift`: the already-loaded `documentNotes` when available, else `cachedDocumentIdentifier`/`documentDisplayName` (populated by `loadNotes` regardless of whether the store had prior notes) so a `DocumentNotes` container is created on first use — a document with only imported notes has `documentNotes == nil` (T-1865).  `updateNote`, `deleteNote`, `toggleStatus`, `reattachNote` operate on the already-loaded `documentNotes.identifier` and need no source parameter. 
specs/bugfixes/replies-to-imported-only-notes-discarded/report.md Added +82 / -0
diff --git a/specs/bugfixes/replies-to-imported-only-notes-discarded/report.md b/specs/bugfixes/replies-to-imported-only-notes-discarded/report.mdnew file mode 100644index 0000000..133ef95--- /dev/null+++ b/specs/bugfixes/replies-to-imported-only-notes-discarded/report.md@@ -0,0 +1,82 @@+# Bugfix Report: Replies to Imported-Only Document Notes Are Silently Discarded++**Date:** 2026-08-10+**Status:** Fixed++## Description of the Issue++Replying to a document-level note (via NotesPanel or SidebarNotesView) silently did nothing when the current document had only imported notes and no user-created notes.++**Reproduction steps:**+1. Open a document that has imported document-level notes (e.g., extracted `[!COMMENT]` blocks) but has never had a user note created in it — `documentNotes` stays `nil` for the whole session.+2. Open the document-level note thread in NotesPanel (iOS) or SidebarNotesView (macOS/iPad) and reply to the imported root note.+3. The reply UI accepts the input, but no reply is created, nothing is persisted, and no error is surfaced.++**Impact:** Low-to-moderate severity, silent data loss. Any user attempting to reply to an imported note on a document without prior user notes loses their reply with no feedback.++## Investigation Summary++- **Symptoms examined:** `createReply(content:to:)` in `NotesManager+DocumentLevel.swift` is the overload NotesPanel/SidebarNotesView call (they have no direct access to the document source needed by the source-aware overload).+- **Code inspected:** `NotesManager+DocumentLevel.swift` (`createReply(content:to:)`, `createDocumentNote(content:)`), `NotesManager.swift` (`createReply(content:to:source:sessionID:)`, `createReply(content:to:context:)`, `loadNotes`, `cachedDocumentIdentifier`).+- **Hypotheses tested:** Confirmed the shared implementation `createReply(content:to:context:)` already builds a fallback `DocumentNotes` container when `documentNotes` is nil (`notes.notes.append` on a freshly constructed container) — so the bug is isolated to the caller guard, not the shared logic.++## Discovered Root Cause++`createReply(content: String, to parentNote: BlockNote)` guarded with `guard iCloudAvailable, let notes = documentNotes else { return }`. When `documentNotes` is `nil` (a document that only ever had imported notes), the `let notes = documentNotes` binding fails and the function returns early — before it ever reaches the shared `createReply(content:to:context:)` implementation that knows how to synthesize a `DocumentNotes` container.++Its sibling, `createDocumentNote(content:)` in the same file, already handles this correctly: when `documentNotes` is nil it falls back to `cachedDocumentIdentifier`/`documentDisplayName` (populated by `loadNotes` regardless of whether the store had any notes) before proceeding.++**Defect type:** Logic error — early-return guard used a wrong/incomplete precondition instead of the fallback the sibling method already used.++**Why it occurred:** `createReply(content:to:)` was likely written by directly reusing `documentNotes` as "the" source of context without noticing that `createDocumentNote(content:)`, added in the same file, already needed to solve the identical nil-context problem via `cachedDocumentIdentifier`.++**Contributing factors:** No test previously exercised `createReply(content:to:)` against a `documentNotes == nil` precondition — all existing document-level reply tests (`testDocumentNoteReply_sameTypeSentinel`, etc.) first call `createDocumentNote` to establish a root note, which as a side effect always populates `documentNotes`, masking the guard's bug.++## Resolution for the Issue++**Changes made:**+- `prism/Services/NotesManager+DocumentLevel.swift` — `createReply(content:to:)` now mirrors `createDocumentNote(content:)`: build `context` from `documentNotes` when available, else fall back to `cachedDocumentIdentifier`/`documentDisplayName`, and only return early if neither is available.++**Approach rationale:** The shared `createReply(content:to:context:)` implementation already does the right thing given a context; the bug was purely in how the caller derived (or failed to derive) that context. Mirroring the exact fallback pattern already used one function below keeps the file internally consistent and requires no changes to the shared logic.++**Alternatives considered:**+- Changing the shared `createReply(content:to:context:)` to accept an optional context and resolve it internally — rejected: it already takes an explicit context by design (used directly by the source-aware overload with its own resolved context), so pushing resolution logic into it would blur responsibilities.+- Making `documentNotes` non-optional / auto-vivified on load — rejected: much larger blast radius across the codebase for a fix that only needs to happen at the two document-level convenience call sites.++## Regression Test++**Test file:** `prismTests/NotesManagerDocumentLevelTests.swift`+**Test name:** `testCreateReply_createsContainerWhenDocumentNotesIsNilImportedOnly`++**What it verifies:** Loads notes for a document with no saved user notes (`documentNotes` stays `nil`, `cachedDocumentIdentifier` gets populated by `loadNotes`), constructs an imported-style root `BlockNote`, then calls `createReply(content:to:)`. Asserts the reply is appended to a newly created `documentNotes` container, has the correct `threadId`, and is persisted (`store.saveCount == 1`) — i.e. is not silently discarded.++**Run command:**+```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' \+  -only-testing:prismTests/NotesManagerDocumentLevelTests/testCreateReply_createsContainerWhenDocumentNotesIsNilImportedOnly test+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/NotesManager+DocumentLevel.swift` | Fixed `createReply(content:to:)` to fall back to `cachedDocumentIdentifier`/`documentDisplayName` when `documentNotes` is nil, mirroring `createDocumentNote(content:)` |+| `prismTests/NotesManagerDocumentLevelTests.swift` | Added regression test for the imported-only-document reply path |++## Verification++**Automated:**+- [x] Regression test passes+- [x] Full unit test suite passes (`make test-quick`)+- [x] Linter passes (`make lint`)++## Prevention++**Recommendations to avoid similar bugs:**+- When two sibling convenience methods (`createDocumentNote(content:)` / `createReply(content:to:)`) both need to derive document context from possibly-nil `documentNotes`, factor the fallback into one shared helper rather than duplicating the `if let ... else if let cachedDocumentIdentifier ... else return` chain — a future third caller could otherwise reintroduce the same bug independently.+- Regression tests for document-level note operations should include an explicit `documentNotes == nil` precondition case (imported-only document), not just the common path where a prior `createDocumentNote` call incidentally populates it.++## Related++- Transit ticket: T-1865
specs/bugfixes/replies-to-imported-only-notes-discarded/implementation.md Added +50 / -0
diff --git a/specs/bugfixes/replies-to-imported-only-notes-discarded/implementation.md b/specs/bugfixes/replies-to-imported-only-notes-discarded/implementation.mdnew file mode 100644index 0000000..579148b--- /dev/null+++ b/specs/bugfixes/replies-to-imported-only-notes-discarded/implementation.md@@ -0,0 +1,50 @@+# Implementation Explanation: Replies to Imported-Only Document Notes (T-1865)++Generated during pre-push review of branch `T-1865/bugfix-replies-to-imported-only-notes-discarded`.++## Beginner Level++### What Changed+Prism lets you attach notes to a document, and other people's notes can be imported into it (for example comment blocks that came along with the file). You can reply to any note. Before this fix, replying to an imported note did nothing — no error, no reply — if you had never written a note of your own in that document. Now the reply is created and saved as expected.++### Why It Matters+A user typed a reply, tapped send, and it vanished. That is silent data loss: the worst kind of bug, because nothing tells you it happened.++### Key Concepts+- **Document-level note**: a note attached to the whole document rather than a specific paragraph.+- **Imported note**: a note that came from outside (extracted from the file), as opposed to one you created.+- **`DocumentNotes` container**: the on-disk record holding all your notes for one document. It is only created the first time you write a note. A document where you've only ever *seen* imported notes has no container yet — and the reply code refused to work without one, instead of creating it.++## Intermediate Level++### Changes Overview+- `prism/Services/NotesManager+DocumentLevel.swift`: `createReply(content:to:)` — the no-source-parameter convenience overload used by `NotesPanel` (iOS) and `SidebarNotesView` (macOS/iPad) — no longer requires `documentNotes` to be non-nil. Context resolution now goes through a new private computed property `currentDocumentContext`, shared with `createDocumentNote(content:)`.+- `prismTests/NotesManagerDocumentLevelTests.swift`: regression test `testCreateReply_createsContainerWhenDocumentNotesIsNilImportedOnly`.+- `CHANGELOG.md`, bugfix report, and `docs/agent-notes/notes-system.md` updated.++### Implementation Approach+The shared implementation `createReply(content:to:context:)` in `NotesManager.swift` already synthesized a `DocumentNotes` container when `documentNotes` was nil (`documentNotes ?? DocumentNotes(identifier:displayName:)`); it just needed a context (identifier + display name). The bug was entirely in the caller: `guard iCloudAvailable, let notes = documentNotes else { return }` bailed out before reaching that logic. The fix derives the context from `documentNotes` when present, else from `cachedDocumentIdentifier`/`documentDisplayName` — both populated unconditionally by `loadNotes` regardless of whether the store had any notes. This is exactly the fallback the sibling `createDocumentNote(content:)` already used; during review the now-identical chain was extracted into one `currentDocumentContext` helper so a future third caller cannot reintroduce the bug.++### Trade-offs+- **Fix the caller, not the shared implementation**: making `createReply(content:to:context:)` accept an optional context and resolve it internally was rejected — the source-aware overload passes its own resolved context by design, and blurring that would spread resolution logic across layers.+- **No auto-vivification of `documentNotes`**: making the container always exist after load would have a much larger blast radius than a two-call-site context fix.++## Expert Level++### Technical Deep Dive+- The reply inherits `parentNote.blockId` (the document sentinel for document-level notes) and `threadRoot = parentNote.threadId ?? parentNote.noteHash`, so replying to an imported *root* correctly threads on the root's `noteHash`. The parent is treated as an opaque value — it is never looked up in `importedNotes` — which is why the fix needs no imported-collection awareness.+- Actor safety: `NotesManager` is `@MainActor`; `currentDocumentContext` is read and the container mutation in the shared implementation completes synchronously before the first suspension point (`await persistNotes`), so a concurrent `loadNotes` cannot interleave between context capture and container creation. Exactly one `store.save` occurs (asserted by the regression test).+- Staleness: `cachedDocumentIdentifier` and `documentPath` (backing `documentDisplayName`) are set together at the top of every `loadNotes` and kept in lockstep by `migrateNotes`, so a non-nil identifier implies a current display name.++### Architecture Impact+Every other `guard ... documentNotes else { return }` in `NotesManager` operates on an existing note by ID (update/delete/toggle/reattach), where nil correctly means "nothing to do". Only the two no-source *creation* conveniences needed the fallback; both now share `currentDocumentContext`. The source-aware creation entry points resolve context from `DocumentSource` via `resolveNoteContext` and were never susceptible.++### Potential Issues+- Silent no-op remains when iCloud is unavailable or no document has been loaded — consistent with the house convention throughout `NotesManager`, but still invisible to the user; surfacing an error is a separate design discussion.+- The regression test establishes the `documentNotes == nil` precondition via `loadNotes` against an empty store but does not register the imported root via `setImportedNotes`; functionally equivalent today because the parent is opaque, but if reply creation ever starts consulting `importedNotes`, the test should be extended.++## Completeness Assessment++- **Fully implemented**: the reply fallback for imported-only documents; regression test covering the nil-`documentNotes` path including persistence; duplication removed via `currentDocumentContext`; CHANGELOG, bugfix report, and agent-notes updated.+- **Partially implemented**: nothing.+- **Missing**: nothing required. (Out of scope: user-visible feedback when note operations silently no-op without iCloud.)

Things to double-check

Concurrent loadNotes during reply creation.

Context is captured before the first await, and the container mutation in the shared implementation is synchronous up to await persistNotes. On @MainActor that span is atomic, so a same-document reload can only interleave after documentNotes is updated. The efficiency agent verified this; if reply creation ever gains an await before the mutation, revisit.

Silent no-ops without iCloud.

The reply is still dropped without feedback when iCloudAvailable is false or no document is loaded. Consistent with every sibling method, but it is the same UX shape as the bug just fixed — if users report it, the answer is an error surface across NotesManager, not another guard tweak.