prism branch T-1813/bugfix-direct-open-fragments commits 3 files 13 touched lines +1091 / -35 targeted tests 166 / 166 passed merge onto main clean

Pre-push review: T-1813 direct-open fragments

Round-4 state of PR #417. Reviewed git diff origin/main...HEAD against a re-fetched origin/main (now at a7b134c8, two PRs ahead of the merge base). Strictly read-only: nothing in the worktree was modified, and the verification run used a git archive export.

At a glance

  • Every boundary in scope is covered — verified, not assumed. All five PrismURL.splittingFragment sites (handleOpenURL’s prism://open, http(s) and file branches; openRecentFile’s .url case; URLInputSheet) and all three PrismURL.nonEmptyFragment sites in LinkPathResolver (lines 176, 202, 213) plus the shared helper at 129. Both PendingAction deferrals carry the fragment. openRecentFile’s .bookmark case and handleFileImport cannot carry one — URL(fileURLWithPath:) escapes # to %23, itself pinned by literalHashInFilenameIsNotAFragment. prism://bundled is documented as uncovered with no producer today.
  • Round 3’s completion is real. resolveRelativePath — the branch most cross-document links take — now calls nonEmptyFragment on all three of its splits, and LinkPathResolverEmptyFragmentTests pins each one paired with the same shape carrying a real fragment, so “report nil” cannot be satisfied by dropping fragments wholesale.
  • The corpus keeps its strength. The expectation table is untouched: all 11 rows still demand fragment == testCase.encodedFragment, path and query are still read off the fetch URL, and a nil or mangled pendingFragment still fails the row. The driver stopped asserting exactly one thing — that the fragment is absent from the fetch URL — and that is pinned separately by two dedicated tests (finding 2).
  • The T-2275 rule holds. Making the corpus loader succeed now builds a real DocumentSession, but documentServices is nil in every one of these tests, so activateSession’s clearPersistedState() and addRemoteURLRecentFile callback both no-op. Nothing reaches RecentFilesManager, StatePersistence or SessionFileManager.
  • The author’s rejection of a fail-closed split(_:) is correct. split is one half of absoluteURL(fromAmbiguousText:)’s split/encode/rejoin, where ? and # are present-but-empty; folding them away would make the normaliser rewrite the URL it was handed. emptyComponentsSurvive pins that end, splitKeepsEmptyComponentsPresent the other. Placing the rule at the navigation boundary is the right call.
  • Four documentation inaccuracies (findings 3–6) — two of them claims in Decision 12 and a code comment that I checked against the code and found overstated. None affect behaviour; all are cheap to correct.

Verdict

Ready to push

No blocking or major defect in the diff. The empty-fragment rule is now applied at every navigation boundary the branch claims — I verified all eight split sites and every entry point independently rather than taking the commit message's word, and the two branch-specific failure modes from earlier rounds (the relative branch still yielding "", the corpus driver reading the fragment off a URL that no longer carries it) are both genuinely closed. 166/166 targeted tests pass across twelve URL-adjacent suites, swiftlint is clean, and the branch merges onto the moved origin/main with no conflict — including the CHANGELOG, which the two intervening PRs happened to touch in different hunks.

One finding is worth reading before you merge, and it is deliberately not graded as blocking. The rule's own justification — an empty fragment is not harmless: scrollToAnchor("") slug-matches, and an emoji-only heading slugs to "" too — has two live consumers that reach scrollToAnchor without passing through either splitter, so [Back to top](#) still lands on a symbol-only heading. That hole exists identically on main; this branch neither introduces nor widens it, and closing it is a one-line consumer-side guard that belongs in its own change. See finding 1.

Review findings

12 raised · 0 fixed · 12 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 25

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

A web address can end in an anchor — the #getting-started on the end of https://example.com/doc.md#getting-started. It means “open this document and jump to the ‘Getting Started’ heading”.

Prism already knew how to do that when you tapped a link inside a document. But when you opened a document the direct way — pasting a URL into the Open URL box, dropping a file on the window, following a prism://open link, or reopening something from Recents — the anchor was quietly thrown away and the document opened at the top.

This change makes those direct routes do what links already did: cut the anchor off the address before fetching, hand it to the part of the app that already knows how to scroll to a heading, and keep it out of the address Prism stores.

Why it matters

Two reasons. The obvious one is that anchors now work everywhere instead of only from links. The less obvious one is about identity: Prism remembers a remote document by its address, so if the anchor stayed glued to that address, opening the same document at two different headings would look like two different documents — two entries in Recents for one file.

Key concepts

  • Fragment: the #anchor part of a URL. It is never sent to the server; it is an instruction to the program that already has the document.
  • Splitting at the boundary: taking the anchor off at the moment a document is opened, rather than deeper inside, so everything downstream sees one clean address.
  • An empty anchor is no anchor: a URL ending in a bare # used to produce an anchor of empty text, which — through an odd quirk of how Prism turns headings into anchor names — could match a heading made entirely of emoji. Now it is treated as no anchor at all.

Architecture

The fragment pipeline already existed and was correct: requestOpenFile(url:fragment:) and requestOpenRemoteURL(_:fragment:) accept a fragment, PendingAction.openFileURL/.openRemoteURL carry it through the Unsaved Changes deferral, session activation writes it to DocumentSession.pendingFragment, and DocumentReaderView’s post-parse .task consumes it once via scrollToAnchor. Only LinkPathResolver fed that pipeline. The five direct-open call sites passed the whole URL and never extracted the fragment.

The fix adds one helper, PrismURL.splittingFragment(from:) -> (url: URL, fragment: String?), and calls it at each of those sites. It reads URLComponents.percentEncodedFragment — the encoded accessor, matching LinkPathResolver’s convention, since scrollToAnchor decodes for itself — and returns the URL with the fragment cleared.

Patterns

One rule, two splitters. Prism splits two different things: a constructed URL (the new helper) and a raw markdown destination string (the pre-existing textual PrismURL.split, which must run before percent-encoding because .urlPathAllowed excludes ? and #). Both routes end at pendingFragment. Round 2 put the empty-fragment answer inside the URL splitter only, which left [Text](./guide.md#) — the far more common link shape — still producing "". Round 3 extracts the rule as PrismURL.nonEmptyFragment(_:) and has both splitters’ consumers call it rather than restate it.

Fail closed on the degrading branch. The deleted LinkPathResolver.removingFragment did components?.url ?? url while its caller kept the fragment separately — on failure that returned the URL still carrying its fragment alongside a non-nil fragment, i.e. the original bug doubled. The new helper returns (url, nil) instead, degrading to pre-fix behaviour.

Trade-offs

Decision 12 records where the fragment lives: on neither DocumentSource.url’s display (the identity key) nor its remote (the relative-resolution base), only on the transient handoff. The honest cost is written down: the anchor is not persisted, so Recents and session restore lose it; a refresh re-fetches display and so does not re-apply it; and the address Prism shows omits the anchor the user typed. Two placements are explicitly out of scope — a fragment on the outer prism://open URL, and prism://bundled/{name}#here, which has no anchor field to carry one and no producer today.

Deep dive: is the rule complete?

This branch was found incomplete on exactly this axis twice, so I traced it exhaustively rather than reading the commit message. Every production site that can turn an address into a scroll target:

  • DocumentFlowCoordinator.handleOpenURLprism://open (splits the target from inside url=, correctly, since URLComponents consumes the outer # before queryItems is read), http(s), file. All three split.
  • openRecentFile .url — splits, which matters because a pre-fix entry is the bug’s own persisted output.
  • URLInputSheet.validateAndSubmit — splits after absoluteURL(fromAmbiguousText:) validation.
  • LinkPathResolver.resolveAbsoluteURL (line 129) via the shared helper; resolveRelativePath’s absolute-filesystem (176), root-relative-remote (202) and plain-relative (213) branches via nonEmptyFragment.
  • openRecentFile .bookmark and handleFileImport — structurally cannot carry a fragment; URL(fileURLWithPath:) escapes # to %23 before a delimiter exists, pinned by literalHashInFilenameIsNotAFragment.
  • prism://bundled — documented as uncovered.

That is complete for the declared scope. What it is not complete for is the hazard the rule cites. DocumentSession.scrollToAnchor has four callers, and two of them never see a splitter: WebDocumentMessageRouter.routeLink intercepts href.hasPrefix("#") and calls scrollToAnchor(String(href.dropFirst())) directly, and LinkPathResolver’s own step-1 anchor-only branch manufactures .anchorOnly("") for destination == "#". I confirmed the first end to end: InlineHTMLRenderer.visitLink emits <a href="#">, prism-scroll.js:813 posts getAttribute("href") verbatim, and anchorSlug filters to alphanumerics ∪ {-,_}, so ## 🎉 slugs to "" and the empty anchor matches it. Pre-existing on main, untouched here.

Deep dive: does the corpus still prove what it proved?

URLEncodingCorpusTests exists to make “fix one branch, leave the siblings” impossible: one table × every conversion path. The .deepLink path used to read all three components off the fetch URL. Since the fetch URL no longer carries a fragment, the driver now assembles URLConversionOutcome from the fetch URL (path, query) plus flow.currentSession?.pendingFragment (fragment).

Strength on the encoding axis is intact. The expectation is URLConversionOutcome(expecting:fileExtension:), unchanged, and all 11 corpus rows carry non-empty fragments (sec one, a%2Fb c, café ☕, a%23b%3Fc d, …), so a double-escape or a lost fragment on the deep-link path still fails the row.

One invariant left the corpus. outcome.fragment is overwritten unconditionally, so the “original bug doubled” shape — fragment left on the fetch URL and routed to pendingFragment — would now pass all 11 rows. That shape is the exact thing splittingFragment’s fail-closed comment warns about, and it is pinned, but only for two concrete shapes rather than per row: deepLinkFragmentSplitFromFetchURL and the rewritten deepLinkMixedFragmentDoesNotDoubleEscape, both asserting fetched == https://example.com/doc.md. A one-line #expect(URLConversionOutcome(url: fetched).fragment == nil) before the overwrite would restore it per row and keep the driver self-contained.

Edge cases

  • components.fragment = nil vs percentEncodedFragment = nil: equivalent. The decoding setter’s re-encoding hazard applies only to non-nil assignment; clearing is not an encoding step. The same idiom already sits at LinkPathResolver:196.
  • The fail-closed guard let strippedURL = components.url is effectively unreachable — removing a component from components derived from a valid absolute URL cannot make them unrepresentable — but it is cheap and it documents the direction, which is what the deleted helper got wrong.
  • resolvingAgainstBaseURL: false means the helper would drop a relative URL’s base. No caller passes one; worth a precondition line now that the helper is shared.
  • Applying nonEmptyFragment at line 213 also changes non-navigation output: classifyLocal/classifyRemote re-embed a non-nil fragment via embeddingFragment, so [x](./page.html#) now hands the system browser page.html rather than page.html#. Harmless, undeclared, untested.

Important changes — detailed

PrismURL: one split helper, one emptiness rule

prism/Services/PrismURL.swift

Why it matters. This is the whole fix in two functions. splittingFragment(from:) is the single URL-level split every direct-open site now uses; nonEmptyFragment(_:) is the single statement of 'an empty fragment is no fragment', called by both splitters' consumers rather than restated per branch. Rounds 1-3 of this branch are the history of getting that factoring right.

What to look at. prism/Services/PrismURL.swift:166-233

Takeaway. When the same rule has to hold across two implementations of the same idea (a URL splitter and a string splitter), extract the RULE, not the implementation — and have each site call it. Extracting only the first splitter is what left the second one wrong for two review rounds.
Rationale. Stated in the doc comments and Decision 12. The rule sits at the navigation boundary rather than inside split(_:) because split is one half of absoluteURL(fromAmbiguousText:)'s split/encode/rejoin round trip, where an empty component is present-but-empty and must survive — pinned from both ends by emptyComponentsSurvive and splitKeepsEmptyComponentsPresent.

splittingFragment fails closed instead of returning the doubled bug

prism/Services/PrismURL.swift

Why it matters. The deleted LinkPathResolver.removingFragment did `components?.url ?? url` while its caller kept the fragment separately — on the degrading path that returned a URL STILL CARRYING its fragment alongside a non-nil fragment, i.e. the original T-1813 defect doubled. The replacement returns (url, nil).

What to look at. prism/Services/PrismURL.swift:194-206

Takeaway. A `?? original` fallback is only safe when the caller does not also act on the value you derived. Pair a fallback with what the caller does next, or it degrades into an inconsistent state rather than a previous one.
Rationale. Explicit in the helper's doc comment: 'Degrading to pre-fix behaviour on the one unreachable branch is the safe direction.' I could construct no URL shape that reaches it, which the author also says.

LinkPathResolver's three relative/absolute-path splits adopt the rule

prism/Services/LinkPathResolver.swift

Why it matters. Round 2 migrated only resolveAbsoluteURL — the scheme-carrying branch. resolveRelativePath handles relative and absolute-filesystem destinations, which is the shape most cross-document links actually have, and it still split with PrismURL.split, whose fragment for a bare trailing `#` is "". That reached pendingFragment and then scrollToAnchor(""), so the defect this branch claims to close was still standing on the common route.

What to look at. prism/Services/LinkPathResolver.swift:174-176, 197-203, 212-213

Takeaway. 'Migrated onto the shared helper' is not the same as 'the rule now holds'. Ask which branch the majority of traffic takes, and check that one first — it is the one most likely to have been skipped precisely because it did not need the helper's other feature.
Rationale. Commit 2e267887's message states it directly, and the author reports mutation-checking it: removing the rule from the relative branches fails exactly the four new tests and no pre-existing one.

The corpus driver reads the fragment from where it now lands

prismTests/URLEncodingCorpusTests.swift

Why it matters. This branch was failing 11 URLEncodingCorpusTests rows on the .deepLink path for two rounds while main was green, and nobody caught it: the fix takes the fragment off the fetch URL and the driver still read it back from there. The driver now reads pendingFragment and compares it against the SAME expectation, which keeps the 'un-mangled on every path' assertion rather than relaxing it.

What to look at. prismTests/URLEncodingCorpusTests.swift:98-113, 167-201

Takeaway. When a fix moves a value from one carrier to another, a cross-cutting test matrix that reads the old carrier fails for the right reason but looks like a regression. Re-point the reader, do not weaken the expectation — and check the matrix is green on the branch, not just the suites you wrote.
Rationale. Stated in commit 2e267887 and the bugfix report. The knock-on — the injected loader now has to SUCCEED, because a failed load produces no session to carry a fragment — is called out in a comment at the loader stub.

Decision 12 records where a fragment may not live, with its costs

specs/open-from-url/decision_log.md

Why it matters. The fragment had to go somewhere or nowhere, and after Decision 11 a remote document has two URLs with different jobs: `display` (identity for recents/notes/title, and what a refresh re-fetches) and `remote` (the post-redirect response URL used as the relative-resolution base). The entry rejects both and names three real consequences rather than presenting the choice as free.

What to look at. specs/open-from-url/decision_log.md:415-517

Takeaway. Promoting a quick decision to a full ADR is the right move once you can name genuine rejected alternatives AND consequences. The three negatives here (the anchor is not persisted, a refresh does not re-apply it, the stored address omits it) are what make it a trade-off rather than an obvious call.
Rationale. The promotion is explained in commit e70342b9: Q3 gave two wrong reasons (a fragment is not 're-requested on every refresh' — it never goes on the wire — and `remote` is the RESPONSE URL) and omitted the three consequences.

Key decisions

The empty-fragment rule sits at the navigation boundary, not inside <code>PrismURL.split</code>.

Making split(_:) itself fold an empty component to nil was tried first and rejected with evidence. split is one half of absoluteURL(fromAmbiguousText:)’s split/encode/rejoin round trip, so a present-but-empty ? or # has to survive it or the normaliser silently rewrites the URL it was given. URLEncodingCorpusTests.emptyComponentsSurvive pins that end and the new splitKeepsEmptyComponentsPresent pins the other. I agree with the placement: emptiness is a routing question, and routing is not split’s job.

A fragment on the outer <code>prism://open</code> URL is ignored rather than adopted.

url= carries the whole target address, so an anchor belongs inside it escaped as %23; URLComponents consumes the outer # before queryItems is read, so it cannot corrupt the target either. Now pinned by deepLinkOuterFragmentIsIgnored and deepLinkOuterFragmentDoesNotOverrideInner, which turns an omission into a decision.

The scope call is fine. The stated reason is over-argued — see finding 5.

<code>prism://bundled/{name}#fragment</code> is left uncovered.

PendingAction.openBundled carries a resource name and no anchor, and nothing produces such a link today. Recorded in Decision 12’s Impact, in the helper’s doc comment, in the report’s Scope boundary and in the CHANGELOG, all of which now say “the direct-open entry points” rather than the earlier, inaccurate “every”. Correctly scoped and correctly disclosed.

<code>openRecentFile</code>&rsquo;s <code>.url</code> case splits too.

A recents entry recorded before this fix holds a fragment on its stored display URL — the entry is the bug’s own persisted output — so without splitting here it would reproduce the original symptom every time it is reopened. Good catch; this is the kind of thing a fix usually misses.

The comment’s closing claim about “collapsing the forked identity” does not hold, though — see finding 4.

<code>URLInputSheet.onSubmit</code> widened from <code>(URL) -&gt; Void</code> to <code>(URL, String?) -&gt; Void</code>.

Inferred from the diff: the alternative — splitting in the prismApp.swift closure and leaving the sheet’s signature alone — is not discussed anywhere. The chosen shape does match ResolvedLink.remoteMarkdown(URL, fragment:) and requestOpenRemoteURL(_:fragment:), so it is at least consistent with the surrounding convention, but it does put a routing rule in a View whose single caller forwards both values straight on.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorWebDocumentMessageRouter.swift:366 / LinkPathResolver.swift:63-67The empty-fragment rule does not reach the two scrollToAnchor callers that bypass both splitters. `[Back to top](#)` emits <a href="#">, prism-scroll.js posts the href verbatim, routeLink calls scrollToAnchor(""), and anchorSlug filters to alphanumerics + hyphen + underscore, so a heading like `## 🎉` slugs to "" and the empty anchor matches it — verbatim the failure mode PrismURL.nonEmptyFragment's doc comment describes. LinkPathResolver's own step-1 anchor-only branch has the same hole (destination "#" -> .anchorOnly("") -> DocumentReaderView:463). Verified end to end. All three review agents found this independently. Graded minor, not major, because the defect exists identically on origin/main: this branch neither introduces nor widens it, and the branch's rule IS complete for every route it claims. Also note LinkPathResolverEmptyFragmentTests' own docstring says "Every branch of LinkPathResolver is pinned here", which is not quite true — .anchorOnly is a branch and is neither routed through the rule nor tested.Not fixed (read-only review, and out of this PR's scope). The cheapest complete fix is consumer-side rather than at a fifth call site: `guard !normalized.isEmpty else { return }` after the lowercase in DocumentSession.scrollToAnchor (line ~490). That closes all four routes at once, cannot regress anything the PR added, and is a natural follow-up ticket. If it stays out of scope, correct the new suite's docstring and add the branch to the report's Scope boundary.
minorprismTests/URLEncodingCorpusTests.swift:112-113The corpus driver stopped asserting that the fragment is absent from the fetch URL. `URLConversionOutcome(url: fetched)` reads percentEncodedFragment off the fetch URL, then line 113 overwrites it unconditionally with pendingFragment — so the 'original bug doubled' shape (fragment left on the fetch URL AND handed to pendingFragment) would now pass on all 11 rows. That is the exact pairing splittingFragment's fail-closed comment exists to prevent. Assessment of the user's question: the corpus's ENCODING strength is fully preserved (unchanged expectation table, all 11 rows carry non-empty fragments, path and query still read off the fetch URL), and the missing routing invariant is pinned by deepLinkFragmentSplitFromFetchURL and deepLinkMixedFragmentDoesNotDoubleEscape — but for two concrete shapes rather than per corpus row.Not fixed. One line before the overwrite restores it per row and keeps the driver self-contained: #expect(URLConversionOutcome(url: fetched).fragment == nil, "the deep-link fetch URL must not carry the fragment"). Separately confirmed clean: making the loader succeed does NOT violate the T-2275 session-store rule — documentServices is nil in these tests, so activateSession's clearPersistedState() and the addRemoteURLRecentFile callback both no-op.
minorspecs/open-from-url/decision_log.md:448 and specs/bugfixes/direct-open-fragments/report.md:195The 'forked identity' claim overstates by two of three items. Decision 12's Rationale says a fragment left on `display` gives 'two recent-files entries, two window titles and two notes namespaces'. Verified against the code: only the recents entry forks. DocumentIdentifierResolver.resolve(forRemoteURL:) builds the notes identifier from scheme + host + URLComponents.path (+query) and never reads the fragment; DocumentSource.urlDisplayTitle uses lastPathComponent, which for https://example.com/doc.md#a is 'doc.md' either way. The decision's conclusion is unaffected — one forked recents entry is enough to justify it — but the stated cost is inflated.Not fixed. Reduce the claim to recents entries in both places. The CHANGELOG entry already states it correctly ('a URL opened at two different anchors no longer risks two separate recent-file entries').
minorprism/ViewModels/DocumentFlowCoordinator.swift:427The openRecentFile comment claims splitting 'lets the reopen re-record the entry under the fragment-free URL, collapsing the forked identity'. It does not collapse anything. RecentFilesManager.addEntry's .url case removes only an entry whose displayURL.absoluteString matches EXACTLY, so reopening a legacy '…doc.md#getting-started' entry inserts a second, fragment-free entry and leaves the old one in the list until it ages out. The split does fix the scroll — which is the point of the change and is correct.Not fixed. Drop the last clause of the comment, and the matching sentence in the report's openRecentFile bullet.
minorprism/ViewModels/DocumentFlowCoordinator.swift:236-243The prism://open outer-fragment comment justifies the behaviour by claiming a raw-typed link is 'indistinguishable from a deliberate outer fragment on a properly escaped link'. Nothing in Prism gives an outer fragment any meaning today — both cases drop it identically — so there is no behaviour being preserved and therefore no ambiguity to resolve. A fallback (remoteFragment ?? nonEmptyFragment(components.percentEncodedFragment)) would recover the raw-typed case and still pass deepLinkOuterFragmentDoesNotOverrideInner, since the inner fragment is checked first. The decision to leave it out of scope is defensible; the stated reason is not.Not fixed. Either adopt the two-token fallback, or reword to 'deliberately out of scope — an outer # has no meaning today and adopting it would give it one'.
minorspecs/open-from-url/design.md:321-346The handleOpenURL snippet shows the prism://open and file branches but omits the http/https branch that production now has (DocumentFlowCoordinator.swift:245-247). Because the snippet is immediately followed by an explicit 'the prism://bundled branch does NOT split a fragment', it reads as an exhaustive enumeration while missing one of the three entry points the CHANGELOG and report both name. (Pre-existing and untouched: line 320 still places handleOpenURL in MainContentView; it lives on DocumentFlowCoordinator.)Not fixed. Add the two-line http/https branch to the snippet.
minorprism/Services/LinkPathResolver.swift:213 (with :176, :202)Applying nonEmptyFragment at these sites also changes NON-navigation output, which the change is not framed as touching. classifyLocal/classifyRemote re-embed a non-nil fragment for non-markdown targets via embeddingFragment, and PrismURL.percentEncoded("", as: .fragment) is "", which URLComponents renders as a trailing '#'. So [x](./page.html#) used to hand the system browser https://…/page.html# and now hands it https://…/page.html. Practically harmless — an empty fragment is a scroll-to-top hint at most — but it is an undeclared side effect, and LinkPathResolverEmptyFragmentTests only exercises markdown targets, so nothing would catch a regression in either direction.Not fixed. One case in LinkPathResolverEmptyFragmentTests asserting .externalURL for 'page.html#', plus a clause in the LinkPathResolver:114-128 comment noting the non-markdown re-embed also drops the bare #.
nitspecs/bugfixes/direct-open-fragments/report.md:284, 296-297Two stale round-2 statements in an otherwise accurate report. The Affected Files cell says LinkPathResolver's relative branches 'inherit the empty-fragment rule via split(_:)' — they do not, and the whole point of round 3 is that split(_:) deliberately keeps "" while the branches call nonEmptyFragment explicitly. And the Verification list ('DocumentFlowCoordinatorURLTests, PrismURLSplittingFragmentTests — 160 tests') omits LinkPathResolverEmptyFragmentTests, PrismURLNonEmptyFragmentTests and the URLEncodingCorpusTests update, all of which the report's own Run command does list.Not fixed. Reword to '…apply the rule by calling PrismURL.nonEmptyFragment', and refresh the verification counts to the round-4 run.
nitprism/Services/PrismURL.swift:195splittingFragment uses URLComponents(url:resolvingAgainstBaseURL: false) and returns components.url, so a URL built with URL(string:relativeTo:) would come back having lost its base. No caller passes one today — all five sites pass absolute URLs — but the helper is now shared and its doc comment does not state the precondition.Not fixed. One line in the doc comment ('expects an absolute URL'), or resolvingAgainstBaseURL: true.
nitprismTests/DocumentFlowCoordinatorURLTests.swift and prismTests/URLEncodingCorpusTests.swiftTest-harness proliferation. DocumentFlowCoordinatorURLTests now holds three near-identical private deep-link drivers — fetchedURL(forDeepLink:) :149, sessionOutcome(forDeepLink:) :251 (new), outcome(forDeepLink:) :391 — differing only in the loader body (throw vs succeed) and the returned tuple. httpsURLFragmentSplitBeforeFetch (:265-280) inlines sessionOutcome's body verbatim even though the helper builds its URL from a plain string and would accept an https:// one unchanged; only the argument label reads 'forDeepLink'. Across files, URLEncodingCorpusTests.openedDeepLinkTarget is now a functional clone of sessionOutcome, alongside a duplicate private URLCapture actor (both files declare one). Four test files build the same RemoteContentCoordinator(loader:) stub while prismTests/Support/ holds no shared harness.Not fixed, and not worth blocking on — most of the duplication predates this PR, which added one more clone rather than creating it. If it is ever consolidated, one Support/RemoteOpenHarness.swift returning (fetched, pendingFragment, error) with a loader-outcome parameter would replace all four.
nitprism/Views/URLInputSheet.swift:22 / prism/prismApp.swift:383onSubmit widened from (URL) -> Void to (URL, String?) -> Void so the sheet can perform the split. The sheet's job is validate-and-hand-back; it now does URL surgery and its doc comment has to explain remote/display identity, while its single call site forwards both values straight on to requestOpenRemoteURL. On the 'stringly-typed tuple' question specifically: (URL, String?) matches ResolvedLink.remoteMarkdown(URL, fragment:) and requestOpenRemoteURL(_:fragment:), so a bespoke type here alone would add inconsistency rather than remove it — the placement is the quibble, not the shape.Not fixed. If it ever bothers you: revert onSubmit to (URL) -> Void and split in the prismApp.swift closure. Note this path has no test of its own (grep URLInputSheet over prismTests/prismUITests finds nothing); it is covered at the helper level only, which the report's 'What it verifies' section should say.
nitprism/Services/GitHubURLTransformer.swift:83The transformer's fragment-preservation line (rawComponents.percentEncodedFragment = originalComponents.percentEncodedFragment) is now dead in production: after this diff every URL reaching URLDocumentLoader.load is fragment-free, since all five direct-open sites split and LinkPathResolver carries the fragment out of band. It is correct defensive behaviour and unit-tested, so it should stay — but a future reader will mistake it for a live path.Not fixed. A line in the transformer's comment noting the input is fragment-free in production since T-1813 would prevent that.

Tests

Source: local run at 2026-09-06T19:35:00+10:00 · snapshot 2e2678874eb11fbcc27e151e23784bc32eb8cab4

Baseline: none

Execution: passed (partial results) · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

The test runner could not be detected.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 118ef1da0dd53d5036b3740c96cedd3578b6643d.

Dependents edges at package granularity Changed edges at package granularity Dependencies none found prismTests prismTests/Support prismTests/WebRendering . prism prism/Services prism/ViewModels prism/Views prismTests …ugfixes/direct-open-fragments specs/open-from-url prismTests/MockNotesBackupStore.swiftprismTests/MockNotesBackupStore.swift prismTests/MockNotesStore.swiftprismTests/MockNotesStore.swift prismTests/Support/IsolatedSessionStore.swift…s/Support/IsolatedSessionStore.swift prismTests/Support/LiveWebKitBudget.swift…Tests/Support/LiveWebKitBudget.swift prismTests/WebRendering/ParityFixtureSupport.swift…Rendering/ParityFixtureSupport.swift prismTests/WebRendering/WebDocumentLiveHarness.swift…ndering/WebDocumentLiveHarness.swift prismTests/WebRendering/WebNavigationPrecedenceHarness.swift…WebNavigationPrecedenceHarness.swift CHANGELOG.mdCHANGELOG.md prism/prismApp.swiftprism/prismApp.swift⚑295 prism/Services/LinkPathResolver.swift…rvices/LinkPathResolver.swift prism/Services/PrismURL.swiftprism/Services/PrismURL.swift prism/ViewModels/DocumentFlowCoordinator.swift…DocumentFlowCoordinator.swift prism/Views/URLInputSheet.swift…ism/Views/URLInputSheet.swift prismTests/DocumentFlowCoordinatorURLTests.swift…FlowCoordinatorURLTests.swift prismTests/LinkPathResolverEmptyFragmentTests.swift…olverEmptyFragmentTests.swift prismTests/PrismURLSplittingFragmentTests.swift…LSplittingFragmentTests.swift prismTests/URLEncodingCorpusTests.swift…/URLEncodingCorpusTests.swift specs/bugfixes/direct-open-fragments/report.md…rect-open-fragments/report.md specs/open-from-url/decision_log.md…open-from-url/decision_log.md specs/open-from-url/design.mdspecs/open-from-url/design.md
addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

prism/Services/PrismURL.swift Modified +79 / -0
diff --git a/prism/Services/PrismURL.swift b/prism/Services/PrismURL.swiftindex 950de7e1..d4376536 100644--- a/prism/Services/PrismURL.swift+++ b/prism/Services/PrismURL.swift@@ -163,6 +163,76 @@ enum PrismURL {         return parse(normalized) ?? parsed     } +    /// Splits an already-constructed URL into its fragment-free form and its+    /// percent-encoded fragment (`nil` when there is none).+    ///+    /// The fragment is returned encoded, matching `LinkPathResolver`'s+    /// convention (`URLComponents.percentEncodedFragment`, not the decoding+    /// `.fragment` accessor) — a caller that consumes it as slug text+    /// (`DocumentSession.scrollToAnchor`) decodes it itself.+    ///+    /// Used at the direct-open entry points (Open URL sheet, `handleOpenURL`'s+    /// http(s)/`prism://open`/file branches, and remote recent-file opens) to+    /// keep the fragment out of the fetch/file URL and out of remote document+    /// identity, routing it instead through the existing `pendingFragment`+    /// handoff (T-1813), and by ``LinkPathResolver`` for in-document links.+    ///+    /// Not every open goes through here: `prism://bundled/{name}#fragment`+    /// still drops its fragment, because `PendingAction.openBundled` carries a+    /// resource name and no anchor. Nothing produces such a link today.+    ///+    /// Two edge cases are answered deliberately rather than by accident:+    ///+    /// - **An empty fragment is no fragment**, per ``nonEmptyFragment(_:)``,+    ///   the rule this shares with ``split(_:)``.+    /// - **Failure fails closed.** If the URL cannot be taken apart, the+    ///   original URL is returned with `nil` — never the untouched URL+    ///   *alongside* a non-nil fragment. That pairing would be the original+    ///   T-1813 bug doubled: the fragment left on the fetch/file URL AND+    ///   handed to `pendingFragment`. Degrading to pre-fix behaviour on the+    ///   one unreachable branch is the safe direction.+    static func splittingFragment(from url: URL) -> (url: URL, fragment: String?) {+        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),+              let fragment = components.percentEncodedFragment else {+            return (url, nil)+        }+        components.fragment = nil+        // No stripped URL ⇒ report no fragment, so the caller opens the URL+        // exactly as it arrived instead of opening it twice-wrong.+        guard let strippedURL = components.url else {+            return (url, nil)+        }+        return (strippedURL, nonEmptyFragment(fragment))+    }++    /// The one rule deciding whether a split-off fragment is an anchor at all.+    ///+    /// **An empty fragment is no fragment.** A bare trailing `#` splits off as+    /// `""`, which is not an anchor anyone can have meant, and passing it on is+    /// not harmless: `DocumentSession.scrollToAnchor("")` slug-matches, and a+    /// heading whose text is entirely emoji or symbols slugs to `""` too — so+    /// the empty fragment would MATCH it and throw away the reading position+    /// just restored. The `#` is still consumed by the split either way (it+    /// identifies no different resource); only the *fragment* becomes `nil`.+    ///+    /// It is stated here, once, because a fragment reaches `pendingFragment`+    /// by two routes and each has its own splitter: a constructed URL goes+    /// through ``splittingFragment(from:)`` (which applies this itself), while+    /// a raw markdown destination goes through the textual ``split(_:)``, whose+    /// fragment `LinkPathResolver` passes through here. Answering per splitter+    /// is what left the fix standing on the scheme-carrying route while+    /// `[Text](./guide.md#)` — the far more common shape — still produced `""`+    /// (T-1813).+    ///+    /// This is a *routing* rule and belongs at that boundary rather than inside+    /// ``split(_:)``: `split` is one half of a normalising round trip in which+    /// an empty component is present-but-empty and must survive+    /// (`URLEncodingCorpusTests.emptyComponentsSurvive`).+    static func nonEmptyFragment(_ fragment: String?) -> String? {+        guard let fragment, !fragment.isEmpty else { return nil }+        return fragment+    }+     /// Resolves a relative reference **whose components are already     /// percent-encoded** against a base URL.     ///@@ -186,6 +256,15 @@ enum PrismURL {     /// textual — no component is encoded or decoded — because splitting must     /// happen *before* encoding: `.urlPathAllowed` excludes `?` and `#`, so     /// encoding first would mangle the separators into the path.+    ///+    /// An **empty** component here is present-but-empty, never absent: a+    /// trailing `?` or `#` yields `""` rather than `nil`. That is deliberate+    /// and pinned by `URLEncodingCorpusTests.emptyComponentsSurvive` — this+    /// function is one half of ``absoluteURL(fromAmbiguousText:)``'s+    /// split/encode/rejoin round trip, so folding an empty component away here+    /// would silently rewrite the URL being normalised. A caller taking a+    /// fragment out of the URL to *navigate* by wants the opposite answer and+    /// asks ``nonEmptyFragment(_:)`` for it.     static func split(_ source: String) -> (path: String, query: String?, fragment: String?) {         let beforeFragment: String         let fragment: String?
prism/Services/LinkPathResolver.swift Modified +25 / -14
diff --git a/prism/Services/LinkPathResolver.swift b/prism/Services/LinkPathResolver.swiftindex 6594d015..9f747b26 100644--- a/prism/Services/LinkPathResolver.swift+++ b/prism/Services/LinkPathResolver.swift@@ -110,8 +110,23 @@ enum LinkPathResolver {         // untouched, so downstream has one convention rather than two.         // `DocumentSession.scrollToAnchor` decodes before slug matching, so         // in-app anchors are unaffected either way.-        let fragment = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedFragment-        let cleanURL = removingFragment(from: url)+        //+        // The split itself lives in `PrismURL.splittingFragment(from:)`, shared+        // with the direct-open entry points (T-1813) — this branch is the+        // convention those adopted, so keeping a second copy here is exactly+        // the pair that would drift. That helper fails closed when the URL+        // cannot be taken apart, and answers an empty fragment (a bare `#`)+        // as `nil` — which matters here, where a `""` fragment would otherwise+        // reach `scrollToAnchor` and match an emoji-only heading.+        //+        // Only THIS branch can use that helper: it takes a constructed `URL`,+        // and the relative branches below have a raw destination string, which+        // is what `PrismURL.split` is for. They therefore state the empty+        // fragment rule by CALLING it — `PrismURL.nonEmptyFragment`, the same+        // one this helper applies — rather than by repeating it, because+        // answering it per branch is exactly what left `[Text](./guide.md#)`+        // still producing `""` after this branch was migrated (T-1813).+        let (cleanURL, fragment) = PrismURL.splittingFragment(from: url)          // file:// scheme handling         if lowercaseScheme == "file" {@@ -156,9 +171,9 @@ enum LinkPathResolver {                 // The query part is intentionally discarded: local file links                 // are resolved by classifyLocal against the filesystem, which                 // has no use for query parameters.-                let (pathPart, _, fragment) = PrismURL.split(source)+                let (pathPart, _, fragmentPart) = PrismURL.split(source)                 let url = URL(fileURLWithPath: pathPart).standardized-                return classifyLocal(url, fragment: fragment)+                return classifyLocal(url, fragment: PrismURL.nonEmptyFragment(fragmentPart))             case .url:                 // Split using our own helper rather than URLComponents(string:),                 // which fails on mixed encoded/raw paths (T-875).@@ -182,7 +197,10 @@ enum LinkPathResolver {                 guard let resolved = components.url else {                     return .unresolvable                 }-                return classifyRemote(resolved.standardized, fragment: fragmentPart)+                return classifyRemote(+                    resolved.standardized,+                    fragment: PrismURL.nonEmptyFragment(fragmentPart)+                )             case .clipboard:                 // Unreachable: early return on line 119 handles clipboard before this point.                 return .unresolvable@@ -191,7 +209,8 @@ enum LinkPathResolver {          // Split source into path, query, and fragment before encoding.         // .urlPathAllowed encodes ? and # which would mangle query/fragment into the path.-        let (pathPart, queryPart, fragment) = PrismURL.split(source)+        let (pathPart, queryPart, fragmentPart) = PrismURL.split(source)+        let fragment = PrismURL.nonEmptyFragment(fragmentPart)          // Every component gets the same selective encode: existing escapes are         // kept verbatim and only genuinely-raw runs are encoded, so the string@@ -353,12 +372,4 @@ enum LinkPathResolver {     private static func isMarkdownExtension(_ ext: String) -> Bool {         markdownExtensions.contains(ext.lowercased())     }--    /// Returns a copy of the URL with the fragment removed.-    private static func removingFragment(from url: URL) -> URL {-        guard url.fragment != nil else { return url }-        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)-        components?.fragment = nil-        return components?.url ?? url-    } }
prism/ViewModels/DocumentFlowCoordinator.swift Modified +32 / -4
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex c2a4777a..48b1bc26 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -222,11 +222,32 @@ final class DocumentFlowCoordinator {                 loadError = URLDocumentLoader.LoadError.unsupportedScheme.errorDescription                 return             }-            requestOpenRemoteURL(remoteURL)+            // Split the fragment off before fetching (T-1813): the fetch/base+            // URL and remote document identity must never carry it, and the+            // existing `pendingFragment` handoff already carries it the rest+            // of the way (see `openRemoteSession`).+            //+            // The fragment is taken from `remoteURL` — the TARGET, i.e. inside+            // the encoded `url=` value — never from the outer `prism://open`+            // URL. A fragment written on the outer URL+            // (`prism://open?url=…#outer`) is ignored, by design and not by+            // omission: `url=` is the whole target address, so an anchor+            // belongs inside it, escaped as `%23`. `URLComponents` splits the+            // outer `#` off before `queryItems` is read, so this costs nothing+            // and cannot corrupt the target — but it does mean a link typed+            // RAW (`prism://open?url=https://ex.com/doc.md#a`) loses its anchor+            // to the outer URL, because there is no way to tell that apart from+            // a deliberate outer fragment on a properly escaped link. Pinned by+            // `deepLinkOuterFragmentIsIgnored` /+            // `deepLinkOuterFragmentDoesNotOverrideInner`.+            let (cleanRemoteURL, remoteFragment) = PrismURL.splittingFragment(from: remoteURL)+            requestOpenRemoteURL(cleanRemoteURL, fragment: remoteFragment)         } else if scheme == "http" || scheme == "https" {-            requestOpenRemoteURL(url)+            let (cleanURL, fragment) = PrismURL.splittingFragment(from: url)+            requestOpenRemoteURL(cleanURL, fragment: fragment)         } else if url.isFileURL {-            requestOpenFile(url: url)+            let (cleanURL, fragment) = PrismURL.splittingFragment(from: url)+            requestOpenFile(url: cleanURL, fragment: fragment)         }     } @@ -398,7 +419,14 @@ final class DocumentFlowCoordinator {         case .bookmark:             requestOpenBookmarkRecentFile(entry)         case .url(let displayURL):-            requestOpenRemoteURL(displayURL)+            // Split here too (T-1813). A recents entry recorded BEFORE this fix+            // can still hold a fragment on its stored `display` URL, and+            // reopening it would otherwise reproduce the original symptom+            // forever — the entry is the bug's own persisted output. Splitting+            // honours the anchor and lets the reopen re-record the entry under+            // the fragment-free URL, collapsing the forked identity.+            let (cleanURL, fragment) = PrismURL.splittingFragment(from: displayURL)+            requestOpenRemoteURL(cleanURL, fragment: fragment)         }     } 
prism/Views/URLInputSheet.swift Modified +10 / -2
diff --git a/prism/Views/URLInputSheet.swift b/prism/Views/URLInputSheet.swiftindex 9d6681be..e7b0ed4a 100644--- a/prism/Views/URLInputSheet.swift+++ b/prism/Views/URLInputSheet.swift@@ -19,7 +19,11 @@ import SwiftUI /// - 2.6: Malformed URL validation before fetch struct URLInputSheet: View {     @Binding var isPresented: Bool-    let onSubmit: (URL) -> Void+    /// Called with the fragment-free URL to fetch and the percent-encoded+    /// fragment split off it (nil when the URL had none). Splitting here+    /// keeps `#fragment` out of the fetch/display URL and identity while+    /// still routing it through the caller's fragment handoff (T-1813).+    let onSubmit: (URL, String?) -> Void      @State private var urlText: String = ""     @State private var errorMessage: String?@@ -107,10 +111,14 @@ struct URLInputSheet: View {             return         } +        // Split off the fragment (T-1813) so it never reaches the fetch/base+        // URL or remote document identity — it travels separately from here.+        let (cleanURL, fragment) = PrismURL.splittingFragment(from: url)+         // Dismiss immediately — the download overlay in MainContentView takes over.         // If the download fails, the user re-opens the sheet via the "Open URL" action.         isPresented = false-        onSubmit(url)+        onSubmit(cleanURL, fragment)     }      // MARK: - Clipboard
prism/prismApp.swift Modified +2 / -2
diff --git a/prism/prismApp.swift b/prism/prismApp.swiftindex c8b791ad..87b2acf7 100644--- a/prism/prismApp.swift+++ b/prism/prismApp.swift@@ -380,8 +380,8 @@ struct MainContentView: View {                 .applyTheme(settings: settings, systemObserver: systemColorSchemeObserver)         }         .sheet(isPresented: $remoteCoordinator.isURLInputPresented) {-            URLInputSheet(isPresented: $remoteCoordinator.isURLInputPresented) { url in-                flowCoordinator.requestOpenRemoteURL(url)+            URLInputSheet(isPresented: $remoteCoordinator.isURLInputPresented) { url, fragment in+                flowCoordinator.requestOpenRemoteURL(url, fragment: fragment)             }         }         .environment(paywall)
prismTests/PrismURLSplittingFragmentTests.swift Added +146 / -0
diff --git a/prismTests/PrismURLSplittingFragmentTests.swift b/prismTests/PrismURLSplittingFragmentTests.swiftnew file mode 100644index 00000000..4b9e3cd9--- /dev/null+++ b/prismTests/PrismURLSplittingFragmentTests.swift@@ -0,0 +1,146 @@+//+//  PrismURLSplittingFragmentTests.swift+//  prismTests+//+//  Created by Claude on 6/9/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for `PrismURL.splittingFragment(from:)` (T-1813).+///+/// Direct URL and file opens (Open URL sheet, `handleOpenURL`, the+/// `prism://open` deep link, file opens) must never let a `#fragment` reach+/// the fetch/file URL or remote document identity — it belongs on the+/// existing `pendingFragment` handoff instead. This is the single function+/// all of those entry points use to make that split.+@Suite("PrismURL splittingFragment(from:)")+struct PrismURLSplittingFragmentTests {++    @Test("A URL with no fragment is returned unchanged with a nil fragment")+    func noFragmentReturnsUnchanged() {+        let url = URL(string: "https://example.com/doc.md")!+        let result = PrismURL.splittingFragment(from: url)+        #expect(result.url == url)+        #expect(result.fragment == nil)+    }++    @Test("A plain fragment is split off the URL")+    func plainFragmentSplitOff() {+        let url = URL(string: "https://example.com/doc.md#getting-started")!+        let result = PrismURL.splittingFragment(from: url)+        #expect(result.url == URL(string: "https://example.com/doc.md")!)+        #expect(result.fragment == "getting-started")+    }++    @Test("A percent-encoded fragment is preserved encoded, not decoded")+    func percentEncodedFragmentPreservedEncoded() {+        let url = URL(string: "https://example.com/doc.md#getting%20started")!+        let result = PrismURL.splittingFragment(from: url)+        #expect(result.url == URL(string: "https://example.com/doc.md")!)+        #expect(result.fragment == "getting%20started")+    }++    @Test("A query string survives the split alongside a fragment")+    func queryStringSurvivesSplit() {+        let url = URL(string: "https://example.com/doc.md?ref=main#getting-started")!+        let result = PrismURL.splittingFragment(from: url)+        #expect(result.url == URL(string: "https://example.com/doc.md?ref=main")!)+        #expect(result.fragment == "getting-started")+    }++    @Test("An empty fragment (a bare trailing #) is reported as no fragment at all")+    func emptyFragmentReportedAsNil() {+        // An empty fragment is not an anchor anyone can have meant, and+        // forwarding it is not harmless: `scrollToAnchor("")` slug-matches,+        // and a heading whose text is entirely emoji or symbols slugs to `""`+        // too — so the empty fragment would MATCH it and jump away from the+        // reading position just restored. The `#` is still stripped off the+        // URL (it identifies no different resource); only the fragment is nil.+        let url = URL(string: "https://example.com/doc.md#")!+        let result = PrismURL.splittingFragment(from: url)+        #expect(result.url == URL(string: "https://example.com/doc.md")!)+        #expect(result.fragment == nil)+    }++    @Test("A file URL fragment is split off the same way as a remote URL")+    func fileURLFragmentSplitOff() {+        let url = URL(fileURLWithPath: "/tmp/doc.md")+        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!+        components.fragment = "getting-started"+        let urlWithFragment = components.url!++        let result = PrismURL.splittingFragment(from: urlWithFragment)+        #expect(result.url == url)+        #expect(result.fragment == "getting-started")+    }++    @Test("A literal '#' in a filename is not mistaken for a fragment delimiter")+    func literalHashInFilenameIsNotAFragment() {+        // The one shape that could turn this fix into data loss: a file whose+        // NAME contains a `#`. `percentEncodedFragment` only fires on an+        // *unescaped* `#` in the URL string, and every way this app builds a+        // file URL (the file importer, drag-and-drop, resolved bookmarks) goes+        // through `URL(fileURLWithPath:)`, which escapes a literal `#` to `%23`+        // before the delimiter can be seen. So the split must report no+        // fragment and hand back the path untouched.+        //+        // Pinned rather than assumed: the invariant belongs to how the file URL+        // is CONSTRUCTED, not to `splittingFragment` itself, so a future change+        // to that construction (say a raw-string `URL(string:)`) would silently+        // start truncating filenames at the `#`. This test fails when it does.+        let url = URL(fileURLWithPath: "/tmp/notes#1.md")+        #expect(url.absoluteString.hasSuffix("notes%231.md"))++        let result = PrismURL.splittingFragment(from: url)+        #expect(result.fragment == nil)+        #expect(result.url == url)+        #expect(result.url.path == "/tmp/notes#1.md")+        #expect(result.url.lastPathComponent == "notes#1.md")+    }+}++/// Tests for `PrismURL.nonEmptyFragment(_:)` and the boundary it sits on+/// (T-1813).+///+/// A fragment reaches `pendingFragment` by two routes with two splitters: a+/// constructed URL through `splittingFragment(from:)` above, and a raw markdown+/// destination through the textual `split(_:)`. `nonEmptyFragment` is the one+/// place either route decides an empty fragment is no fragment, which is what+/// stops the rule holding on one and not the other.+///+/// The division of labour is asserted here too: `split` itself must NOT fold an+/// empty component away. It is one half of `absoluteURL(fromAmbiguousText:)`'s+/// split/encode/rejoin round trip, where an empty component is+/// present-but-empty and has to survive — `URLEncodingCorpusTests`+/// `emptyComponentsSurvive` pins the other end of that.+@Suite("PrismURL nonEmptyFragment(_:)")+struct PrismURLNonEmptyFragmentTests {++    @Test("An empty fragment is no fragment")+    func emptyFragmentIsNil() {+        #expect(PrismURL.nonEmptyFragment("") == nil)+        #expect(PrismURL.nonEmptyFragment(nil) == nil)+    }++    @Test("A real fragment passes through verbatim, still encoded")+    func realFragmentPassesThrough() {+        #expect(PrismURL.nonEmptyFragment("introduction") == "introduction")+        #expect(PrismURL.nonEmptyFragment("getting%20started") == "getting%20started")+    }++    @Test("split(_:) keeps an empty fragment present-but-empty for the round trip")+    func splitKeepsEmptyComponentsPresent() {+        // Distinct from absent, and distinct from what the navigation boundary+        // above wants: folding this to `nil` inside `split` would make+        // `absoluteURL(fromAmbiguousText:)` rewrite the URL it is normalising.+        let bareHash = PrismURL.split("guide.md#")+        #expect(bareHash.path == "guide.md")+        #expect(bareHash.fragment == "")++        let noHash = PrismURL.split("guide.md")+        #expect(noHash.fragment == nil)+    }+}
prismTests/LinkPathResolverEmptyFragmentTests.swift Added +120 / -0
diff --git a/prismTests/LinkPathResolverEmptyFragmentTests.swift b/prismTests/LinkPathResolverEmptyFragmentTests.swiftnew file mode 100644index 00000000..5ab6c4d4--- /dev/null+++ b/prismTests/LinkPathResolverEmptyFragmentTests.swift@@ -0,0 +1,120 @@+//+//  LinkPathResolverEmptyFragmentTests.swift+//  prismTests+//+//  Created by Claude on 6/9/2026.+//++import Foundation+import Testing+@testable import prism++// MARK: - T-1813: An Empty Fragment Is No Fragment++/// A bare trailing `#` is not an anchor anyone can have meant, and `""` is not+/// inert: it reaches `pendingFragment` and then+/// `DocumentSession.scrollToAnchor("")`, which slug-matches — and a heading+/// whose text is entirely emoji or symbols slugs to `""` too, so the empty+/// fragment MATCHES it and throws away the reading position just restored.+///+/// `PrismURL` answers `nil` for it in ONE place (`nonEmptyFragment`), shared by+/// the URL splitter `resolveAbsoluteURL` uses (`splittingFragment(from:)`) and+/// the string splitter `resolveRelativePath` uses (`split(_:)`). Every branch+/// of `LinkPathResolver` is pinned here because the fix first landed on the+/// scheme-carrying branch alone, leaving the far more common relative one —+/// `[Text](./guide.md#)` — still returning `""`.+///+/// Each test pairs the bare `#` with the same shape carrying a real fragment,+/// so "report nil" cannot be satisfied by dropping fragments wholesale.+@Suite("LinkPathResolver empty fragment")+struct LinkPathResolverEmptyFragmentTests {++    @Test("Relative markdown link ending in a bare # has no fragment")+    func relativePathBareHashHasNoFragment() {+        let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+        let target = URL(fileURLWithPath: "/Users/test/Documents/guide.md")++        #expect(LinkPathResolver.resolve(+            destination: "guide.md#",+            baseURL: baseURL,+            sourceType: .file+        ) == .markdownFile(target, fragment: nil))++        #expect(LinkPathResolver.resolve(+            destination: "guide.md#introduction",+            baseURL: baseURL,+            sourceType: .file+        ) == .markdownFile(target, fragment: "introduction"))+    }++    @Test("Absolute filesystem markdown link ending in a bare # has no fragment")+    func absolutePathBareHashHasNoFragment() {+        let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+        let target = URL(fileURLWithPath: "/Users/a/doc.md")++        #expect(LinkPathResolver.resolve(+            destination: "/Users/a/doc.md#",+            baseURL: baseURL,+            sourceType: .file+        ) == .markdownFile(target, fragment: nil))++        #expect(LinkPathResolver.resolve(+            destination: "/Users/a/doc.md#heading",+            baseURL: baseURL,+            sourceType: .file+        ) == .markdownFile(target, fragment: "heading"))+    }++    @Test("Relative remote markdown link ending in a bare # has no fragment")+    func relativeRemotePathBareHashHasNoFragment() {+        let baseURL = URL(string: "https://example.com/docs/")!+        let target = URL(string: "https://example.com/docs/guide.md")!++        #expect(LinkPathResolver.resolve(+            destination: "guide.md#",+            baseURL: baseURL,+            sourceType: .url+        ) == .remoteMarkdown(target, fragment: nil))++        #expect(LinkPathResolver.resolve(+            destination: "guide.md#introduction",+            baseURL: baseURL,+            sourceType: .url+        ) == .remoteMarkdown(target, fragment: "introduction"))+    }++    @Test("Root-relative remote markdown link ending in a bare # has no fragment")+    func rootRelativeRemotePathBareHashHasNoFragment() {+        let baseURL = URL(string: "https://example.com/docs/project/")!+        let target = URL(string: "https://example.com/other/readme.md")!++        #expect(LinkPathResolver.resolve(+            destination: "/other/readme.md#",+            baseURL: baseURL,+            sourceType: .url+        ) == .remoteMarkdown(target, fragment: nil))++        #expect(LinkPathResolver.resolve(+            destination: "/other/readme.md#usage",+            baseURL: baseURL,+            sourceType: .url+        ) == .remoteMarkdown(target, fragment: "usage"))+    }++    @Test("Scheme-carrying markdown link ending in a bare # has no fragment")+    func absoluteURLBareHashHasNoFragment() {+        let target = URL(string: "https://example.com/docs/readme.md")!++        #expect(LinkPathResolver.resolve(+            destination: "https://example.com/docs/readme.md#",+            baseURL: nil,+            sourceType: .file+        ) == .remoteMarkdown(target, fragment: nil))++        #expect(LinkPathResolver.resolve(+            destination: "https://example.com/docs/readme.md#usage",+            baseURL: nil,+            sourceType: .file+        ) == .remoteMarkdown(target, fragment: "usage"))+    }+}
prismTests/DocumentFlowCoordinatorURLTests.swift Modified +188 / -3
diff --git a/prismTests/DocumentFlowCoordinatorURLTests.swift b/prismTests/DocumentFlowCoordinatorURLTests.swiftindex db3a7bef..30df1114 100644--- a/prismTests/DocumentFlowCoordinatorURLTests.swift+++ b/prismTests/DocumentFlowCoordinatorURLTests.swift@@ -230,12 +230,158 @@ struct DocumentFlowCoordinatorURLTests {         )!)     } -    @Test("Deep link with a mixed encoded/raw fragment does not double-escape (T-2140)")+    @Test("Deep link with a mixed encoded/raw fragment does not double-escape (T-2140/T-1813)")     func deepLinkMixedFragmentDoesNotDoubleEscape() async {-        let url = await fetchedURL(+        // Before T-1813 the (correctly escaped) fragment stayed embedded in+        // the fetch URL. It must now be split off — kept out of the fetch+        // URL and remote identity — and routed through pendingFragment+        // instead, still correctly escaped.+        let result = await sessionOutcome(             forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Ffile.md%23a%2520b%20c"         )-        #expect(url == URL(string: "https://example.com/file.md#a%20b%20c")!)+        #expect(result.fetched == URL(string: "https://example.com/file.md")!)+        #expect(result.pendingFragment == "a%20b%20c")+    }++    // MARK: - T-1813: direct URL/file opens ignore document fragments++    /// Drives `handleOpenURL` with a deep link whose loader succeeds+    /// immediately, returning the URL actually fetched and the resulting+    /// session's `pendingFragment`.+    private func sessionOutcome(forDeepLink deepLink: String) async -> (fetched: URL?, pendingFragment: String?) {+        let capture = URLCapture()+        let remote = RemoteContentCoordinator(loader: { url in+            await capture.record(url)+            return URLDocumentLoader.LoadResult(content: "# Doc", fetchURL: url, displayURL: url)+        })+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)++        flow.handleOpenURL(URL(string: deepLink)!)+        await remote.downloadTask?.value+        return (await capture.last, flow.currentSession?.pendingFragment)+    }++    @Test("handleOpenURL splits a fragment off a direct https URL before fetching")+    func httpsURLFragmentSplitBeforeFetch() async {+        let capture = URLCapture()+        let remote = RemoteContentCoordinator(loader: { url in+            await capture.record(url)+            return URLDocumentLoader.LoadResult(content: "# Doc", fetchURL: url, displayURL: url)+        })+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)++        flow.handleOpenURL(URL(string: "https://example.com/doc.md#getting-started")!)+        await remote.downloadTask?.value++        #expect(await capture.last == URL(string: "https://example.com/doc.md")!)+        #expect(flow.currentSession?.pendingFragment == "getting-started")+    }++    @Test("handleOpenURL splits a percent-encoded fragment off a prism://open deep link")+    func deepLinkFragmentSplitFromFetchURL() async {+        let result = await sessionOutcome(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fdoc.md%23getting-started"+        )+        #expect(result.fetched == URL(string: "https://example.com/doc.md")!)+        #expect(result.pendingFragment == "getting-started")+    }++    @Test("A fragment on the OUTER prism://open URL is ignored, not adopted (T-1813)")+    func deepLinkOuterFragmentIsIgnored() async {+        // The placement this fix deliberately does not handle. `url=` is the+        // whole target address, so an anchor belongs INSIDE it (escaped as+        // `%23`); a `#` written on the outer `prism://open` URL is not part of+        // the target and is dropped. Unsupported by design — and now pinned,+        // rather than merely untested, so a change here is a decision someone+        // makes on purpose.+        //+        // The sharp edge this documents: a deep link typed raw rather than+        // escaped puts its anchor here, and so loses it. Recovering it is not+        // possible without guessing, since a raw link is indistinguishable+        // from a properly escaped one carrying a deliberate outer fragment.+        let result = await sessionOutcome(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fdoc.md#outer"+        )+        #expect(result.fetched == URL(string: "https://example.com/doc.md")!)+        #expect(result.pendingFragment == nil)+    }++    @Test("An outer prism://open fragment never overrides the target's own (T-1813)")+    func deepLinkOuterFragmentDoesNotOverrideInner() async {+        // Both placements at once: the target's own fragment wins and the+        // outer one is discarded, so the outer `#` can never redirect a+        // correctly formed link to a different anchor.+        let result = await sessionOutcome(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fdoc.md%23inner#outer"+        )+        #expect(result.fetched == URL(string: "https://example.com/doc.md")!)+        #expect(result.pendingFragment == "inner")+    }++    @Test("Reopening a legacy recents entry splits the fragment off its stored URL (T-1813)")+    func recentURLEntryFragmentSplitBeforeFetch() async {+        // A recents entry written BEFORE this fix can still carry a fragment+        // on its stored `display` URL — the entry is the bug's own persisted+        // output, so without splitting here it would reproduce the original+        // symptom every time it is reopened, indefinitely.+        let capture = URLCapture()+        let remote = RemoteContentCoordinator(loader: { url in+            await capture.record(url)+            return URLDocumentLoader.LoadResult(content: "# Doc", fetchURL: url, displayURL: url)+        })+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)++        // Built through the same initialiser the app used to record it, so+        // this is the shape a pre-fix entry actually has on disk.+        let entry = RecentFileEntry(+            displayURL: URL(string: "https://example.com/doc.md#getting-started")!+        )+        flow.openRecentFile(entry)+        await remote.downloadTask?.value++        #expect(await capture.last == URL(string: "https://example.com/doc.md")!)+        #expect(flow.currentSession?.pendingFragment == "getting-started")+    }++    @Test("handleOpenURL splits a fragment off a file URL before opening")+    func fileURLFragmentSplitBeforeOpen() throws {+        let flow = DocumentFlowCoordinator()++        let tempDir = FileManager.default.temporaryDirectory+        let fileURL = tempDir.appendingPathComponent("T1813-\(UUID().uuidString).md")+        try "# Heading\n\nBody".write(to: fileURL, atomically: true, encoding: .utf8)+        defer { try? FileManager.default.removeItem(at: fileURL) }++        var components = URLComponents(url: fileURL, resolvingAgainstBaseURL: false)!+        components.fragment = "getting-started"+        let urlWithFragment = components.url!++        flow.handleOpenURL(urlWithFragment)++        #expect(flow.currentSession?.source.url == fileURL)+        #expect(flow.currentSession?.pendingFragment == "getting-started")+    }++    @Test("handleOpenURL decodes a percent-encoded fragment off a file URL before opening")+    func fileURLPercentEncodedFragmentSplitBeforeOpen() throws {+        let flow = DocumentFlowCoordinator()++        let tempDir = FileManager.default.temporaryDirectory+        let fileURL = tempDir.appendingPathComponent("T1813-\(UUID().uuidString).md")+        try "# Getting Started\n\nBody".write(to: fileURL, atomically: true, encoding: .utf8)+        defer { try? FileManager.default.removeItem(at: fileURL) }++        var components = URLComponents(url: fileURL, resolvingAgainstBaseURL: false)!+        components.percentEncodedFragment = "getting%20started"+        let urlWithFragment = components.url!++        flow.handleOpenURL(urlWithFragment)++        #expect(flow.currentSession?.source.url == fileURL)+        #expect(flow.currentSession?.pendingFragment == "getting%20started")     }      // MARK: - T-2140: prism://open?url= scheme gate@@ -318,6 +464,45 @@ struct DocumentFlowCoordinatorURLTests {         #expect(result.error?.isEmpty == false)     } +    @Test("handleOpenURL defers a fragment-carrying https open behind unsaved confirmation")+    func httpsURLFragmentDeferredByUnsavedConfirmation() {+        let remote = RemoteContentCoordinator(loader: Self.hangingLoader)+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)+        flow.currentSession = DocumentSession(clipboardContent: "# Unsaved clipboard content")++        flow.handleOpenURL(URL(string: "https://example.com/doc.md#getting-started")!)++        // The fragment must survive the confirmation detour: it is carried on+        // the deferred PendingAction rather than lost when the open is+        // deferred behind the Unsaved Changes dialog (T-1813).+        #expect(flow.showUnsavedConfirmation == true)+        #expect(flow.pendingAction == .openRemoteURL(+            URL(string: "https://example.com/doc.md")!, fragment: "getting-started"+        ))+        #expect(remote.isDownloading == false, "the open must not run before the user confirms")+    }++    @Test("handleOpenURL defers a fragment-carrying file open behind unsaved confirmation")+    func fileURLFragmentDeferredByUnsavedConfirmation() throws {+        let flow = DocumentFlowCoordinator()+        flow.currentSession = DocumentSession(clipboardContent: "# Unsaved clipboard content")++        let tempDir = FileManager.default.temporaryDirectory+        let fileURL = tempDir.appendingPathComponent("T1813-\(UUID().uuidString).md")+        try "# Heading\n\nBody".write(to: fileURL, atomically: true, encoding: .utf8)+        defer { try? FileManager.default.removeItem(at: fileURL) }++        var components = URLComponents(url: fileURL, resolvingAgainstBaseURL: false)!+        components.fragment = "getting-started"+        let urlWithFragment = components.url!++        flow.handleOpenURL(urlWithFragment)++        #expect(flow.showUnsavedConfirmation == true)+        #expect(flow.pendingAction == .openFileURL(fileURL, fragment: "getting-started"))+    }+     @Test("openFile cancels an in-flight remote download")     func openFileCancelsRemoteDownload() async throws {         let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
prismTests/URLEncodingCorpusTests.swift Modified +29 / -6
diff --git a/prismTests/URLEncodingCorpusTests.swift b/prismTests/URLEncodingCorpusTests.swiftindex 0a599a5f..8f1dfc6f 100644--- a/prismTests/URLEncodingCorpusTests.swift+++ b/prismTests/URLEncodingCorpusTests.swift@@ -98,7 +98,20 @@ struct URLEncodingCorpusTests {          case .deepLink:             let target = ambiguousAbsolute(testCase, fileExtension: ext)-            return await Self.fetchedURL(forDeepLinkTarget: target).map(URLConversionOutcome.init(url:))+            guard let opened = await Self.openedDeepLinkTarget(target),+                  let fetched = opened.fetched else {+                return nil+            }+            // The one path whose fragment does not ride the produced URL: since+            // T-1813 the coordinator splits it off before fetching and routes+            // it to `DocumentSession.pendingFragment`, so that the fetch URL+            // and the remote document identity stay anchor-free. It still has+            // to arrive un-mangled, which is what this corpus is about, so the+            // outcome is assembled from where each component actually ends up+            // rather than the expectation being relaxed.+            var outcome = URLConversionOutcome(url: fetched)+            outcome.fragment = opened.pendingFragment+            return outcome          case .gitHubTransform:             // The transformer takes a URL, not a string, so feed it one that is@@ -151,14 +164,20 @@ struct URLEncodingCorpusTests {         func value() -> URL? { last }     } -    /// Wraps `target` in a `prism://open?url=` deep link and returns the URL-    /// the remote coordinator would fetch.+    /// Wraps `target` in a `prism://open?url=` deep link and returns where the+    /// target's components ended up: the URL the remote coordinator fetches,+    /// and the fragment the coordinator routed to the session.     ///     /// The target is escaped down to the RFC 3986 unreserved set, so     /// `URLComponents.queryItems` decodes exactly one layer and hands the     /// coordinator `target` byte-for-byte — reproducing the partially-encoded     /// string that is the whole of Defect A.-    private static func fetchedURL(forDeepLinkTarget target: String) async -> URL? {+    ///+    /// The fragment is read separately because since T-1813 it is deliberately+    /// no longer part of the fetch URL (see `.deepLink` above).+    private static func openedDeepLinkTarget(+        _ target: String+    ) async -> (fetched: URL?, pendingFragment: String?)? {         let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))         guard let escaped = target.addingPercentEncoding(withAllowedCharacters: unreserved),               let deepLink = URL(string: "prism://open?url=" + escaped) else {@@ -166,16 +185,20 @@ struct URLEncodingCorpusTests {         }          let capture = URLCapture()+        // The loader has to SUCCEED, not throw: the fragment is read back off+        // the session the open produces, and a failed load produces none. It+        // used to throw `CancellationError`, which was enough while the fetch+        // URL was the only thing being examined.         let remote = RemoteContentCoordinator(loader: { url in             await capture.record(url)-            throw CancellationError()+            return URLDocumentLoader.LoadResult(content: "# Doc", fetchURL: url, displayURL: url)         })         let flow = DocumentFlowCoordinator()         flow.setRemoteCoordinatorForTests(remote)          flow.handleOpenURL(deepLink)         await remote.downloadTask?.value-        return await capture.value()+        return (await capture.value(), flow.currentSession?.pendingFragment)     }      // MARK: - The Product
specs/bugfixes/direct-open-fragments/report.md Added +330 / -0
diff --git a/specs/bugfixes/direct-open-fragments/report.md b/specs/bugfixes/direct-open-fragments/report.mdnew file mode 100644index 00000000..7c034a72--- /dev/null+++ b/specs/bugfixes/direct-open-fragments/report.md@@ -0,0 +1,330 @@+# Bugfix Report: Direct URL and File Opens Ignore Document Fragments++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++Prism supports scrolling to a heading when a document is opened via a link+carrying a `#fragment` (cross-document links routed through+`LinkPathResolver`), but the three *direct* entry points — the Open URL+sheet, `DocumentFlowCoordinator.handleOpenURL` (system `onOpenURL`, macOS URL+drop, and the `prism://open?url=` deep link), and file opens routed through+it — passed the whole URL, fragment included, straight to+`requestOpenFile`/`requestOpenRemoteURL` instead of splitting the fragment+off into those methods' existing `fragment:` parameter. The document opened+at its restored/top position instead of the requested heading, and because+the fragment stayed embedded in the remote `display` URL, opening the same+document at two different anchors could also create two separate recent-file+identities for it.++**Reproduction steps:**+1. Use "Open URL" (or a `prism://open?url=…` deep link, or drag a `file://…#fragment`+   URL onto the macOS window) to open `https://example.com/doc.md#getting-started`.+2. The document loads successfully.+3. Observe: the reader lands at the top of the document instead of scrolling+   to the "Getting Started" heading.++**Impact:** The direct-open entry points (Open URL sheet, `onOpenURL`,+macOS URL drop, `prism://open` deep link, direct file opens) silently+dropped a document fragment, as did reopening a remote entry from Recents.+Cross-document link navigation (via `LinkPathResolver`) was unaffected for a+real anchor — it already split fragments correctly — but a link ending in a+bare `#` produced `fragment: ""`, which `scrollToAnchor` matches against an+emoji- or symbol-only heading; that is fixed here too, by the shared+empty-fragment rule. Two paths remain uncovered by design and+are documented under "Scope boundary" below: an anchor on the *outer*+`prism://open` URL, and `prism://bundled/{name}#fragment`.++## Investigation Summary++- **Symptoms examined:** A fragment supplied to a direct entry point never+  reached `DocumentSession.pendingFragment`, so `DocumentReaderView`'s+  post-parse scroll-to-fragment step had nothing to consume.+- **Code inspected:** `prism/ViewModels/DocumentFlowCoordinator.swift`+  (`handleOpenURL`, `requestOpenFile`, `requestOpenRemoteURL`,+  `openFile`, `openRemoteSession`), `prism/Views/URLInputSheet.swift`,+  `prism/prismApp.swift` (the URL-sheet callback and `PendingAction`),+  `prism/Services/LinkPathResolver.swift` (the working convention for+  cross-document links), `prism/Services/PrismURL.swift`,+  `prism/Models/DocumentSource.swift` and+  `specs/open-from-url/decision_log.md` Decision 11 (T-1810, which redefined+  `DocumentSource.url`'s `remote`/`display` split).+- **Hypotheses tested:** Confirmed that `requestOpenFile(url:fragment:)` and+  `requestOpenRemoteURL(_:fragment:)` already accept and correctly thread a+  fragment through session activation (`session.pendingFragment = fragment`)+  and through the unsaved-confirmation deferral (`PendingAction.openFileURL`/+  `.openRemoteURL` already carry `fragment:`) — the gap was solely at the+  three call sites that never extracted the fragment from the URL before+  calling them.++## Discovered Root Cause++**Defect type:** Missing step in three otherwise-correct call sites (an+established pattern — split-fragment-before-open — was not applied at every+entry point that needed it).++**Why it occurred:** `LinkPathResolver` (used for in-document link taps)+already splits a link's fragment from its URL and threads it through+`ResolvedLink.markdownFile(_:fragment:)` /+`.remoteMarkdown(_:fragment:)` to `requestOpenFile`/`requestOpenRemoteURL`.+The three *direct* entry points were written against the same+`fragment:`-accepting methods but never performed the equivalent split —+`URLInputSheet.validateAndSubmit` forwarded its validated `URL` as-is,+`DocumentFlowCoordinator.handleOpenURL`'s http(s)/`prism://open`/file-URL+branches passed the raw URL straight into `requestOpenRemoteURL`/+`requestOpenFile`, and `prismApp.swift`'s URL-sheet callback had no fragment+parameter to forward even if the sheet had extracted one.++**Contributing factors:** T-1810 (merged shortly before this ticket) redefined+`DocumentSource.url`'s `remote` as the final, post-redirect response URL and+`display` as the user's original, re-fetched-on-refresh URL — neither of+which is naturally "the place a fragment belongs," which made it easy for a+fragment to end up nowhere rather than in the existing `pendingFragment`+handoff. That reasoning is recorded as Decision 12 in+`specs/open-from-url/decision_log.md`.++## Resolution for the Issue++**Changes made:**+- `prism/Services/PrismURL.swift` — added+  `PrismURL.splittingFragment(from:) -> (url: URL, fragment: String?)`, the+  single URL-level helper every direct entry point now uses. It reads the+  fragment via `URLComponents.percentEncodedFragment` (encoded, matching+  `LinkPathResolver`'s convention — `DocumentSession.scrollToAnchor` decodes+  it) and returns the URL with `.fragment = nil`. Two edge cases are answered+  deliberately: an **empty** fragment (a bare trailing `#`) is reported as+  `nil`, because `scrollToAnchor("")` slug-matches and an emoji- or+  symbol-only heading slugs to `""` too, so passing it on could match that+  heading and discard the reading position just restored; and if the URL+  cannot be decomposed the helper **fails closed**, returning the original URL+  with `nil` rather than the fragment-carrying URL alongside a non-nil+  fragment — that pairing would be the original bug doubled.+- `prism/Services/PrismURL.swift` — the empty-fragment rule is extracted to+  `nonEmptyFragment(_:)` and stated in ONE place. Prism has two splitters,+  because it splits two different things: a constructed `URL`+  (`splittingFragment(from:)`) and a raw markdown destination (the textual,+  pre-existing `split(_:)`). Both routes end at the same consumer —+  `LinkPathResolver` reaches `pendingFragment` through the URL splitter for a+  scheme-carrying destination and through the string splitter for a relative or+  absolute-filesystem-path one, which is the far more common shape of a+  cross-document link — so answering the rule per splitter left it fixed on the+  first route while `[Text](./guide.md#)` still produced `fragment: ""` on the+  second. The rule is applied at the *navigation boundary*, not inside+  `split(_:)`: `split` is one half of `absoluteURL(fromAmbiguousText:)`'s+  split/encode/rejoin round trip, where an empty component is+  present-but-empty and must survive (`URLEncodingCorpusTests`+  `emptyComponentsSurvive` pins that), so folding it away there would have made+  the normaliser silently rewrite the URL it was given.+- `prism/Services/LinkPathResolver.swift` — migrated onto the shared helper;+  its private `removingFragment(from:)` (a character-for-character copy of+  the extracted split, with the same non-failing-closed fallback) is deleted.+  The helper's doc comment cites `LinkPathResolver` as the convention it+  matches, so leaving both would have been the pair most likely to drift. Its+  relative and absolute-path branches keep calling `PrismURL.split`, which is+  the right splitter for a raw destination string, and pass its fragment+  through `PrismURL.nonEmptyFragment` — calling the shared rule rather than+  restating it.+- `prism/ViewModels/DocumentFlowCoordinator.swift` — `openRecentFile`'s+  `.url` case splits too: a recents entry recorded *before* this fix can hold+  a fragment on its stored `display` URL, and reopening it would otherwise+  reproduce the original symptom indefinitely.+- `prism/ViewModels/DocumentFlowCoordinator.swift` — `handleOpenURL`'s+  `prism://open` branch, direct http(s) branch, and file-URL branch each now+  call `PrismURL.splittingFragment(from:)` before calling+  `requestOpenRemoteURL(_:fragment:)` / `requestOpenFile(url:fragment:)`.+- `prism/Views/URLInputSheet.swift` — `onSubmit` is now+  `(URL, String?) -> Void`; `validateAndSubmit` splits the fragment off+  the validated URL before invoking it.+- `prism/prismApp.swift` — the URL-sheet's `onSubmit` closure now accepts+  `(url, fragment)` and forwards both to+  `flowCoordinator.requestOpenRemoteURL(url, fragment:)`.+- `specs/open-from-url/decision_log.md` — Decision 12 records that a fragment+  belongs on neither `remote` nor `display`, only on the existing+  fragment/`pendingFragment` handoff. Written first as Quick Decision Q3 and+  promoted to a full entry once it turned out to have real consequences to+  state (the anchor is not persisted, a refresh does not re-apply it, and the+  stored address omits it) and genuine rejected alternatives.++**Approach rationale:** `requestOpenFile`/`requestOpenRemoteURL`,+`PendingAction`, and `DocumentSession.pendingFragment` already form a+correct, tested pipeline for carrying a fragment through unsaved-confirmation+deferral and into the post-parse scroll. The fix only needed to make the+three direct entry points feed that pipeline correctly, at the boundary,+exactly as `LinkPathResolver` already does for in-document links — no+changes to the pipeline itself were necessary.++**Scope boundary — the two fragment placements this fix does not handle.**++*1. A fragment on the outer `prism://open` URL.* For the deep link, the+fragment is taken from the *target* URL, i.e. from inside the encoded `url=`+value. One written on the **outer** URL (`prism://open?url=<encoded>#outer`) is+ignored. That is **unsupported by design, not merely untested**: `url=` carries+the whole target address, so an anchor belongs inside it, escaped as `%23`.+`URLComponents` strips the outer `#` before `queryItems` is read, so an outer+fragment cannot corrupt the target either.++The honest cost of that position: a deep link typed *raw* rather than escaped+(`prism://open?url=https://example.com/doc.md#heading`) puts its anchor on the+outer URL and therefore loses it. Recovering it would mean guessing, since a+raw link is indistinguishable from a properly escaped link carrying a+deliberate outer fragment. The behaviour predates this fix and is unchanged by+it; it is now pinned by `deepLinkOuterFragmentIsIgnored` and+`deepLinkOuterFragmentDoesNotOverrideInner`, so any future change to it is a+deliberate one rather than an accident.++*2. `prism://bundled/{name}#fragment`.* The bundled branch drops its fragment,+because `PendingAction.openBundled` carries a resource name and no anchor —+threading one through would mean changing that case and+`openBundledDocument` for a link nothing produces today. Recorded in+Decision 12's Impact rather than fixed; the doc comment on+`PrismURL.splittingFragment(from:)`, the CHANGELOG entry and this report all+say "the direct-open entry points" rather than "every" one, which the earlier+wording claimed inaccurately.++**Alternatives considered:**+- **Split the fragment inside `requestOpenFile`/`requestOpenRemoteURL`+  themselves:** Rejected — every existing caller (`LinkPathResolver`-routed+  opens, recent files) already passes a fragment-free URL plus a separate+  `fragment:` argument; moving the split inside would be redundant for them+  and obscures that the three broken callers are the actual bug.+- **Keep the fragment on `DocumentSource.url`'s `display`+  for identity:** Rejected — Decision 11 makes `display` the recents/notes+  identity key, so leaving a fragment on it forks that identity per anchor:+  one document opened at two headings becomes two recents entries, two window+  titles and two notes namespaces. Recorded as Decision 12, along with what+  the rejection costs (the anchor is not persisted, so Recents and session+  restore lose it).++## Regression Test++**Test files:**+- `prismTests/PrismURLSplittingFragmentTests.swift` (new) — unit tests for+  `PrismURL.splittingFragment(from:)`: no fragment, plain fragment, a+  percent-encoded fragment (kept encoded, not decoded), a fragment alongside+  a query string, an empty (`#`) fragment, and a file URL fragment. Plus+  `literalHashInFilenameIsNotAFragment` — a file whose NAME contains a `#`+  (`/tmp/notes#1.md`) is left whole with no fragment reported. That invariant+  belongs to how the file URL is CONSTRUCTED (`URL(fileURLWithPath:)` escapes+  the `#` to `%23` before `percentEncodedFragment` can see a delimiter), not to+  `splittingFragment` itself, so it is pinned here to catch a future change to+  that construction silently truncating filenames at the `#`. A second suite in+  the same file, `PrismURLNonEmptyFragmentTests`, pins the shared rule itself+  and the division of labour around it — including that `split(_:)` must keep+  an empty component present-but-empty for the normalising round trip.+- `prismTests/LinkPathResolverEmptyFragmentTests.swift` (new) — pins the rule+  at each of `LinkPathResolver`'s own branches, since the two splitters put it+  on different routes into `pendingFragment`: a relative link, an+  absolute-filesystem-path link, a relative and a root-relative remote link,+  and a scheme-carrying link, each ending in a bare `#`. Every test pairs that+  with the same shape carrying a real fragment, so "report `nil`" cannot be+  satisfied by dropping fragments wholesale.+- `prismTests/URLEncodingCorpusTests.swift` (updated) — the `.deepLink` row of+  the corpus was left failing by this fix and had to be brought with it. That+  suite drives every conversion path over one table and reads path, query and+  fragment back off the URL each path produces; after this change the deep-link+  path deliberately no longer *has* the fragment on its URL, so all 11 rows+  failed against it. The driver now reads the fragment from where the+  coordinator actually routes it (`DocumentSession.pendingFragment`) and+  compares it against the same expectation as before, which keeps the+  "un-mangled on every path" assertion intact rather than relaxing it for this+  one. Its harness's loader had to start SUCCEEDING for that: it threw+  `CancellationError`, which was sufficient while the fetch URL was the only+  thing being examined but produces no session to read a fragment from.+- `prismTests/DocumentFlowCoordinatorURLTests.swift` (updated/extended):+  - `deepLinkMixedFragmentDoesNotDoubleEscape` updated: previously asserted+    the (correctly escaped) fragment stayed embedded in the fetch URL; now+    asserts it is split off the fetch URL and lands on `pendingFragment`,+    still correctly escaped.+  - `httpsURLFragmentSplitBeforeFetch` (new) — a direct `https://…#fragment`+    URL is fetched without its fragment, which lands on+    `DocumentSession.pendingFragment`.+  - `deepLinkFragmentSplitFromFetchURL` (new) — same, via+    `prism://open?url=…`.+  - `fileURLFragmentSplitBeforeOpen` / `fileURLPercentEncodedFragmentSplitBeforeOpen`+    (new) — a `file://…#fragment` URL opens the file at its fragment-free+    path and the (percent-encoded) fragment lands on `pendingFragment`.+  - `httpsURLFragmentDeferredByUnsavedConfirmation` /+    `fileURLFragmentDeferredByUnsavedConfirmation` (new) — a fragment+    survives being deferred behind the Unsaved Changes confirmation, carried+    on `PendingAction.openRemoteURL`/`.openFileURL`.+  - `deepLinkOuterFragmentIsIgnored` /+    `deepLinkOuterFragmentDoesNotOverrideInner` (new) — pin the scope+    boundary above: a fragment on the outer `prism://open` URL is discarded,+    and never overrides the target's own.++**What it verifies:** The fragment-splitting helper's correctness in+isolation, and — end to end — that all three direct entry points (https/http+`onOpenURL`, the `prism://open` deep link, and file opens, including through+the unsaved-confirmation deferral) now route a `#fragment` to+`pendingFragment` instead of discarding it, while the fetch/file URL and+document identity stay fragment-free.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -configuration Debug -derivedDataPath ./DerivedData \+  -resultBundlePath ./DerivedData/t.xcresult -testPlan prism -only-test-configuration "en (base)" \+  -parallel-testing-worker-count 1 \+  -only-testing:prismTests/DocumentFlowCoordinatorURLTests \+  -only-testing:prismTests/PrismURLSplittingFragmentTests \+  -only-testing:prismTests/PrismURLNonEmptyFragmentTests \+  -only-testing:prismTests/LinkPathResolverEmptyFragmentTests \+  -only-testing:prismTests/URLEncodingCorpusTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/PrismURL.swift` | Added `splittingFragment(from:)`; the empty-fragment rule extracted to `nonEmptyFragment(_:)` and shared with the string splitter `split(_:)` |+| `prism/ViewModels/DocumentFlowCoordinator.swift` | `handleOpenURL` splits the fragment off before opening (all three branches) |+| `prism/Views/URLInputSheet.swift` | `onSubmit` now carries the split fragment |+| `prism/prismApp.swift` | Forwards the sheet's fragment to `requestOpenRemoteURL` |+| `prism/Services/LinkPathResolver.swift` | Migrated onto the shared helper; deleted its duplicate `removingFragment(from:)`; its relative branches inherit the empty-fragment rule via `split(_:)` |+| `specs/open-from-url/decision_log.md` | Q3 promoted to Decision 12 (full entry) |+| `specs/open-from-url/design.md` | 6b/6c updated — `onSubmit` signature and the `handleOpenURL` snippet showed the pre-fix, unsplit calls |+| `prismTests/PrismURLSplittingFragmentTests.swift` | New — unit tests for the helper, plus the shared `nonEmptyFragment(_:)` rule and its boundary |+| `prismTests/LinkPathResolverEmptyFragmentTests.swift` | New — the bare-`#` rule pinned on every `LinkPathResolver` branch |+| `prismTests/DocumentFlowCoordinatorURLTests.swift` | Updated one test, added six |+| `prismTests/URLEncodingCorpusTests.swift` | Deep-link path reads the fragment from `pendingFragment`, since the fix takes it off the fetch URL |+| `CHANGELOG.md` | Added `[Unreleased] / Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (`DocumentFlowCoordinatorURLTests`,+      `PrismURLSplittingFragmentTests` — 160 tests, 0 failures)+- [x] Related suites pass (`RemoteContentCoordinatorTests`,+      `LinkPathResolverTests`, `LinkPathResolverRelativeQueryFragmentTests`,+      `LinkPathResolverRootRelativeQueryFragmentTests`,+      `URLDocumentLoaderTests`, `DocumentFlowCoordinatorRecentFileTests`,+      `DocumentSessionTests`, `PrismURLAbsoluteURLTests` — 115 tests, 0+      failures)+- [x] `make lint` passes+- [x] `make build-macos` passes+- Full `make test-quick`/`make test-locales` intentionally not run in this+  pass — three sibling agents were building concurrently in other worktrees.++**Manual verification:** Not performed in this pass; covered by the unit+tests above at the coordinator level (loader-argument capture and+`pendingFragment` assertions), which exercise the same code path a live+open would.++## Prevention++**Recommendations to avoid similar bugs:**+- When a pipeline already has a typed parameter for something (here,+  `fragment:`), audit every call site that could supply it, not just the+  ones already passing it — the parameter's existence does not guarantee+  every producer feeds it.+- `PrismURL.splittingFragment(from:)` is now the one place a `URL` becomes+  "clean URL + fragment"; new direct-open entry points should use it rather+  than re-deriving the split.++## Related++- Transit ticket: T-1813+- Related: T-1810 (`specs/open-from-url/decision_log.md` Decision 11 — the+  `remote`/`display` split this fix's fragment placement reasons about)+- Existing convention this fix aligns with: `prism/Services/LinkPathResolver.swift`
specs/open-from-url/decision_log.md Modified +107 / -0
diff --git a/specs/open-from-url/decision_log.md b/specs/open-from-url/decision_log.mdindex 99e82543..3a0adf8a 100644--- a/specs/open-from-url/decision_log.md+++ b/specs/open-from-url/decision_log.md@@ -6,6 +6,7 @@ |----|------|----------|-----------| | Q1 | 2026-08-17 | Move the URL refresh out of `DocumentReaderView` into `RemoteRefreshFlow` (`prism/ViewModels/`) | A view struct cannot own a cancellable task lifetime, which is what T-1805 needed. Applies the task-tracking + monotonic-epoch pattern already used by `RemoteContentCoordinator` (T-862) and the `@State`-owned flow shape of `ClipboardSaveFlow` — convention application, no contested alternative. | | Q2 | 2026-09-06 | `DocumentSource.url`'s `remote` is the FINAL response URL (post-redirect), and a refresh re-fetches the `display` URL rather than `remote` (T-1810) | promoted to Decision 11 |+| Q3 | 2026-09-06 | A URL's `#fragment` is split off before it reaches `remote` or `display`, and lives only on the `fragment`/`pendingFragment` scroll handoff (T-1813) | promoted to Decision 12 |  ## Decision 1: Use Display URL for Identity, Fetch URL for Downloading @@ -408,3 +409,109 @@ Relative resolution has to use where the content came from, and only the respons - Bugfix report: `specs/bugfixes/redirect-final-url-base/report.md`  ---++## Decision 12: A Fragment Belongs Only on the Scroll Handoff, Not on `remote` or `display`++**Date**: 2026-09-06+**Status**: accepted++### Context++Opening a document by URL or file path can carry a `#fragment` naming a heading+to scroll to. Prism already had a working pipeline for that — `requestOpenFile`/+`requestOpenRemoteURL` take a `fragment:` argument, `PendingAction` carries it+through the Unsaved Changes deferral, and `DocumentSession.pendingFragment` is+consumed by the post-parse scroll — but only `LinkPathResolver` (in-document+links) fed it. The direct entry points passed the whole URL, fragment included,+straight through, so the anchor was silently dropped (T-1813).++Fixing that forces a placement question, because a remote document is+identified by two URLs after Decision 11: `display` (the user's original+address — identity for recents, notes and the window title, and the URL a+refresh re-fetches) and `remote` (the final, post-redirect *response* URL, used+as the base against which relative images and links resolve). Neither obviously+owns an anchor, and the fragment had to go somewhere or nowhere.++### Decision++The fragment is split off the URL at the boundary and stored on neither+`remote` nor `display`. It travels only on the existing+`fragment:`/`pendingFragment` handoff, which is consumed once by the post-parse+scroll and never persisted. `PrismURL.splittingFragment(from:)` is the single+place that split happens, used by the direct-open entry points and by+`LinkPathResolver`.++### Rationale++`display` is the identity key. A fragment left on it forks that identity per+anchor: `doc.md#intro` and `doc.md#setup` become two recent-files entries, two+window titles and two notes namespaces for one document. That is the concrete+cost, and it is enough on its own.++`remote` is the base URL for relative resolution. A base is a location, and a+fragment names a position *within* a document rather than a different one, so+it contributes nothing to a base and only makes the stored value a less+accurate answer to "where did these bytes come from".++More generally the fragment is a client-side instruction: it selects a position+in a document already in hand. It is not part of the address of the resource,+which is why it survives no round trip that matters — it is not sent on the+wire, and neither URL it might be stored on has any use for it.++Finally, `LinkPathResolver` had already settled this convention for+cross-document links; applying it at the direct entry points makes one rule+rather than two, and lets both share one implementation.++### Alternatives Considered++- **Keep the fragment on `display`**: Store the user's address verbatim, anchor+  included - Rejected because `display` is the recents/notes/title identity key+  (Decision 11), so this forks one document into a separate identity per+  anchor. It would also mean a refresh re-fetches a URL carrying an anchor that+  the refresh cannot act on.+- **Keep the fragment on `remote`**: Leave it on the fetched/served URL -+  Rejected because `remote` is the base for resolving relative images and+  links; an anchor is not part of a base, so this stores a value that is wrong+  for its one consumer.+- **Add a persisted anchor field to the source/recents entry**: Carry the+  anchor alongside the identity rather than inside it - Rejected as+  unwarranted: it buys anchor-preserving Recents and session restore (see the+  negative consequences) at the cost of a new persisted field, a recents+  migration, and a fresh question about whether two entries differing only by+  anchor are one entry or two. Worth revisiting if users ask for anchored+  Recents; nothing today does.++### Consequences++**Positive:**+- One identity per document regardless of which anchor it was opened at —+  recents, notes and the window title stay stable+- `remote` remains a pure base URL, correct for relative resolution+- One split, shared by the direct-open entry points and `LinkPathResolver`,+  so the two cannot drift++**Negative:**+- **The anchor is not persisted.** Reopening the document from Recents, or+  after a relaunch through session restore, lands at the stored reading+  position rather than the anchor — the anchor applied once, at open, and was+  then forgotten+- **A refresh does not re-apply the anchor.** `RemoteRefreshFlow` re-fetches+  `display`, which no longer carries it, so a refresh restores the reading+  position instead of returning to the heading+- **The address Prism stores and shows omits the anchor the user typed.** A+  URL copied back out of Recents is the document, not the document at that+  heading++### Impact++- `prism/Services/PrismURL.swift` — `splittingFragment(from:)`, the single split+- `prism/ViewModels/DocumentFlowCoordinator.swift` — `handleOpenURL`'s three+  branches and `openRecentFile`'s `.url` case+- `prism/Views/URLInputSheet.swift`, `prism/prismApp.swift` — the Open URL sheet+  carries the split fragment through `onSubmit`+- `prism/Services/LinkPathResolver.swift` — migrated onto the shared helper+- Not covered: `prism://bundled/{name}#fragment`, whose `PendingAction.openBundled`+  carries a resource name and no anchor+- Bugfix report: `specs/bugfixes/direct-open-fragments/report.md`++---
specs/open-from-url/design.md Modified +22 / -4
diff --git a/specs/open-from-url/design.md b/specs/open-from-url/design.mdindex 536c506f..42bb50a7 100644--- a/specs/open-from-url/design.md+++ b/specs/open-from-url/design.md@@ -299,7 +299,10 @@ A new `URLInputSheet` view presented as a `.sheet` from `MainContentView`. struct URLInputSheet: View {     @State private var urlText: String = ""     @Binding var isPresented: Bool-    let onSubmit: (URL) -> Void+    // The fragment is split off the validated URL and passed alongside it+    // (T-1813, Decision 12) — an anchor belongs on the scroll handoff, never+    // on the URL that becomes `remote`/`display`.+    let onSubmit: (URL, String?) -> Void } ``` @@ -308,6 +311,8 @@ struct URLInputSheet: View { - "Paste" button that reads from clipboard into the text field - "Open" button that validates and submits the URL - Validates URL format on submit; shows inline error for invalid URLs+- Splits any `#fragment` off the validated URL with+  `PrismURL.splittingFragment(from:)` before submitting (Decision 12) - Dismisses on successful submit  #### 6c. URL Scheme Extension@@ -321,14 +326,27 @@ private func handleOpenURL(_ url: URL) {     } else if url.scheme == "prism", url.host == "open",               let components = URLComponents(url: url, resolvingAgainstBaseURL: false),               let urlParam = components.queryItems?.first(where: { $0.name == "url" })?.value,-              let remoteURL = URL(string: urlParam) {-        requestOpenRemoteURL(remoteURL)+              // `queryItems` has already decoded one layer, so the value is+              // only PARTIALLY encoded — the ambiguous entry point, not+              // `URL(string:)`, or surviving escapes get doubled (T-2140).+              let remoteURL = PrismURL.absoluteURL(fromAmbiguousText: urlParam) {+        // Split the anchor off before it can reach `remote`/`display`+        // (T-1813, Decision 12). The fragment is taken from the TARGET URL,+        // inside the encoded `url=` value; one written on the outer+        // `prism://open` URL is not part of the target and is ignored.+        let (cleanURL, fragment) = PrismURL.splittingFragment(from: remoteURL)+        requestOpenRemoteURL(cleanURL, fragment: fragment)     } else if url.isFileURL {-        requestOpenFile(url: url)+        let (cleanURL, fragment) = PrismURL.splittingFragment(from: url)+        requestOpenFile(url: cleanURL, fragment: fragment)     } } ``` +The `prism://bundled/{name}` branch does **not** split a fragment:+`PendingAction.openBundled` carries a resource name and no anchor, and nothing+produces such a link today (Decision 12, Impact).+ #### 6d. Share Sheet (iOS)  Register Prism as a share target for URLs by adding a Share Extension target, or simpler: handle URLs via the existing `onOpenURL` handler when the app is opened from a share sheet action.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex f75bfbce..3814f215 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down. - Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged. - `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.+- Opening a URL or file with a `#fragment` now scrolls to that heading instead of landing at the top (T-1813). The Open URL sheet, `onOpenURL`/dropped-URL handling, and the `prism://open` deep link all passed the fragment along embedded in the URL they gave to the file/remote-open request, rather than through the separate `fragment` parameter those requests already accept and the existing `pendingFragment` scroll handoff already consumes — so it was silently discarded. All three entry points now split the fragment off (via the new `PrismURL.splittingFragment(from:)`) before opening, keeping it out of the fetch/file URL and out of remote document identity — a URL opened at two different anchors no longer risks two separate recent-file entries — and routing it through the existing fragment parameter instead, including when the open is deferred behind the Unsaved Changes confirmation. Reopening a URL from Recents splits it too, so an entry recorded before this fix stops reproducing the old behaviour. Two placements are deliberately not covered: an anchor written on the outer `prism://open?url=…#here` link rather than inside the encoded target, and `prism://bundled/{name}#here`, which has no anchor to carry it on and no producer today.  - Resolved document-level notes no longer vanish from the notes sidebar on iPad and macOS (T-1864). Resolving a note attached to the document as a whole, rather than to a block, dropped it from the Document Notes section, which only lists active notes, without adding it to the Resolved section, because the sidebar built that section from the block-only note set it uses for grouping by heading. The Resolved section is now built from every note, so a resolved document note appears there — matching what the compact Notes panel on iPhone already did — and can be reopened from it. - Images beside a saved-out pasted document now appear as soon as it is saved (T-1784). Saving pasted markdown into a folder left every relative image in it — `![diagram](diagram.png)` and the like — showing the error placeholder, because the rendered document went on looking for images where an unsaved paste keeps them, which is nowhere: it had no folder to read from, so each one was refused before it was read. Closing and reopening the document was the only way to see them. Saving deliberately changes the document in place, keeping its position, its notes and everything already worked out about it, and that is exactly why nothing told the rendered page where the document now lives. It is told now, and the page re-fetches its images at the same reading position, so the images simply appear where they were missing and nothing else about the document moves.

Things to double-check

The one thing to decide before merging: <code>[Back to top](#)</code>.

This is finding 1, restated because it is the one item that deserves a decision rather than a skim. The branch’s rule is complete for every route it claims, and I checked all eight of them. But the hazard the rule is written to prevent — scrollToAnchor("") matching a symbol-only heading — is reachable more easily through a route this branch does not touch than through any route it does: a bare # placeholder link is a common markdown idiom, whereas opening doc.md# is not.

Two honest options. Merge as-is and file the follow-up: the hole is pre-existing, the ticket is about direct opens, and a one-line change to DocumentSession.scrollToAnchor in someone else’s PR is exactly the kind of scope creep that makes bugfix branches hard to review — this one has already grown from three call sites to eight. Or fold it in, on the argument that a rule stated three times in doc comments as a safety property should hold at the consumer, and that guard !normalized.isEmpty else { return } is smaller than the comments explaining why it is not there. I lean to the first, with the follow-up filed in the same session so it does not evaporate.

Verification depth is below the project&rsquo;s own pre-push bar.

CLAUDE.md’s pre-push gate is make build-ios, make build-macos, make test and make test-ui, all with zero warnings. This review ran one targeted macOS run — 166/166 across twelve URL-adjacent suites (the four LinkPathResolver* suites, both new PrismURL suites, PrismURLAbsoluteURLTests, DocumentFlowCoordinatorURLTests, URLEncodingCorpusTests, URLChokepointAdoptionTests) plus make verify-test-isolation and swiftlint, all clean. The bugfix report itself says the full test-quick/test-locales runs were “intentionally not run in this pass” because sibling agents were building concurrently.

Nothing in the diff can plausibly break a suite outside this neighbourhood — the production change is five call sites and two new pure functions — but the full gate has not been run against the round-3/4 state on a quiet machine. Worth doing before the merge, not before the push. Note also that GitHub Actions runs no tests at all now (PR #414), so a green PR says nothing.

Merging onto the moved <code>main</code>.

No conflict. origin/main has advanced two commits since the merge base (0ba0501b T-1929 and a7b134c8 T-1968, PRs #415 and #416) and git merge-tree --write-tree HEAD origin/main produces a tree cleanly. The CHANGELOG add/add you were expecting does not materialise: #415’s entry lands at line 28 and #416’s at 139–140, while this branch inserts at line 37 — three separate hunks with enough context between them.

What I checked that came back clean.
  • components.fragment = nil vs percentEncodedFragment = nil — equivalent; the decoding setter only re-encodes non-nil assignments.
  • The fail-closed guard — effectively unreachable, correctly directioned, and a genuine improvement on the helper it replaces.
  • ImagePathResolver as a possible fourth split site — correctly left alone. It works on raw pre-encoding source strings, and its fragment is either discarded or deliberately re-attached to a fetch URL; nothing downstream consumes an image fragment as an anchor, so nonEmptyFragment’s premise does not exist there.
  • The T-2275 session-store rule — satisfied; see finding 2.
  • openRecentFile’s .bookmark case and handleFileImport — structurally cannot carry a fragment.
  • CLAUDE.md — needs no update. The change adds no subsystem or convention it describes, and Decision 12 plus the bugfix report capture the rule properly.