prism branch T-1810/bugfix-redirect-final-url-base commits 2 files 14 touched lines +615 / -24 production LOC +119 / -21 across 5 files ticket T-1810 (PR #407)

Pre-push review: T-1810/bugfix-redirect-final-url-base

A remote document opened from a URL that redirects now resolves its relative images and links against the address the content was actually served from. Second pass over PR #407: verifying that commit 0de4e02d genuinely closed the three findings from the previous review (refresh target, undocumented design shift, missing end-to-end test) plus the reload-coalescing follow-up.

At a glance

  • All four prior findings verified closed. Refresh now re-fetches the display URL (pinned by requestedURLs.urls == [displayURL]); the contract is documented in five places; RemoteContentCoordinatorTests.openingRedirectedURLResolvesImagesAgainstFinalURL is the missing end-to-end test; and the reload-coalescing follow-up is pinned in both directions.
  • No blocking or major code defect found. Four independent review passes (reuse, quality, efficiency/security, spec/test) plus my own read agree.
  • Security holds. The relative-resource base is now redirect-influenceable, but ChunkedBodyLoader.willPerformHTTPRedirection rejects non-http(s) and credential-bearing targets, and for a .url source ImagePathResolver never yields .localFile — so a hostile redirect gains no fetch capability the document body did not already have by writing absolute URLs.
  • One stale doc comment inside the diffprism/Models/DocumentSession.swift:438-439 asserts the behaviour this very commit removed. Two-line fix; I would take it before pushing.
  • Three stale spec statements outside the diffrequirements.md:66 (a written SHALL), design.md:545 (a snippet that refreshes from remote, now the rejected behaviour), and CLAUDE.md:53 (the synchronizer's reload described as unconditional).
  • Decision Q2 is arguably under-tiered. The project's own format rule reserves the quick-decisions table for resolutions with no genuine alternative; the bugfix report names two, and Q2's prose is longer than several full ADRs in the same file.
  • Test-suite health is an environment problem, not a code problem — but it means this branch has not had a clean full-suite run on this machine, and neither would main.

Verdict

Ready to push

The production change is correct and all four previous review points are genuinely closed. I re-derived the mechanism independently rather than taking the commit message's word for it: redirect targets are scheme- and credential-validated before httpResponse.url can become the base; remote is read by exactly one production site (DocumentSource.imageBaseURL) while every identity path — notes, recents, titles, scroll identifier — reads display; and the new controller.parseRevision == pass.parseRevision guard is sound in both directions, with the opposite scheduling order already covered by reloadDocument's pre-existing isSuperseded guard, so the “one reload per refresh” property holds either way the race lands.

What holds it at a warning rather than a clean green is documentation drift, not a defect. Four written statements now contradict the shipped code, and one of them is inside this diff: DocumentSession.updateRemoteBase's own doc comment still says a refresh “re-downloads from this session's current remote URL”, which is precisely the behaviour commit 0de4e02d removed. The other three are outside the diff — requirements.md Req 4.1, design.md's Refresh Button snippet, and the CLAUDE.md sentence describing the synchronizer's reload as unconditional. None blocks a push; the new contract is recorded in the CHANGELOG, the agent note, the bugfix report and Decision Q2. But the previous review's finding was “undocumented design shift”, and this is the tail of that same finding.

Both full make test-quick runs failed, and neither failure belongs to this change. Run 1: 15 failures. Run 2: 37 failures. The two sets are almost disjoint, every failure sits in a suite this diff does not touch, and each of the three non-WebKit-timeout failures passes in isolation. Run 2's set is dominated by NSCache evictions (ImageCacheTests, SnapshotCacheTests) and WebKit .loadTimedOut — the machine-contention signature this project already documents. A targeted 123-test run over every suite this change touches, plus the three suspicious ones, is green.

Review findings

13 raised · 0 fixed · 13 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 13

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What Changed

When you open a markdown file from a web address, that address sometimes redirects — you ask for one URL and the server says “actually, it lives over here now” and sends you somewhere else. Version aliases like latest.md, shortened links, and CDN endpoints all do this routinely.

Prism was remembering the address you typed as the place the document came from. That is the wrong place. If the document contains a picture written as ![diagram](diagram.png) — a relative reference, meaning “the file sitting next to me” — Prism went looking for it next to the address you typed, not next to where the document actually turned out to live. The picture was never there, so you got an error placeholder instead.

The fix is one line in spirit: remember the address the content arrived from, not the one that was asked for.

Why It Matters

Two things had to stay separate, and this change is mostly about keeping them separate properly:

  • Where the content came from — used for finding pictures and following links inside the document.
  • The address you opened — used for the window title, the recent-files list, and for filing your notes under the right document.

Before this change these were conflated into one wrong answer. Now they are two fields with two jobs, and your notes stay attached to the document even if the alias behind it is re-pointed tomorrow.

Key Concepts

Refresh got subtler than it looks. Once “where it came from” is a redirect's destination, refreshing from that would pin the document there forever — a re-pointed alias would keep serving yesterday's target, and a short-lived signed CDN link would simply stop working. So Refresh goes back to the address you opened and follows the redirect afresh, every time.

The page is redrawn once, not twice. A refresh changes two things at once: new text, and possibly a new place to look for pictures. Handled naively, each change triggers its own redraw — and the second one throws away the first one's restored scroll position, so you lose your place. The change makes both land together so a single redraw covers them.

Architecture

The change threads one corrected value through four layers:

  1. URLDocumentLoader.load returns httpResponse.url ?? transformed.fetchURL as LoadResult.fetchURL instead of the pre-redirect request URL. The final response URL was already in hand — it was being consulted for status and content-type checks and then discarded.
  2. DocumentSource.url(remote:display:) keeps its shape, but remote's meaning shifts from “the URL we fetch” to “the URL that served us”. Only imageBaseURL reads it; display continues to carry identity.
  3. DocumentSession.updateRemoteBase(to:) moves the remote component in place, preserving display. It no-ops for non-.url sources and for an unchanged URL.
  4. RemoteRefreshFlow.refresh re-fetches display and hands the new final URL to reloadContent(markdownString:remoteBase:).

Patterns

Turn-scoped state application. reloadContent applies the base after parseAndApplyBlocks returns, inside the same synchronous stretch that bumped parseRevision. That is what lets WebDocumentStateSynchronizer's coalescing observation pass see blocks, revision and base as one atomic-looking change. The reviewer's first instinct — apply the base before the parse — would have been worse: the pass would fire during the parse's suspension and reload the page at the old revision.

Fail-closed supersession. parseAndApplyBlocks gains a Bool return so a parse superseded under T-718 applies nothing, its base included. Without it, a stale refresh resuming late would write its own redirect target over the winning refresh's.

Deferral by revision comparison. The synchronizer's image-source domain issues a same-revision reloadDocument (the T-1784 clipboard-save path). It now skips that when controller.parseRevision != pass.parseRevision, i.e. when the page on screen is behind the session — because a revision load is already owed by DocumentScrollContent's .task(id: WebLoadKey(...)) and will fetch every image against the freshly written context.

Trade-offs

Refreshing from display costs one extra redirect hop per Refresh, uncacheable (the loader builds a fresh ephemeral session per call). Against a manual, user-initiated button that is free. It also introduces a genuine new failure mode that the report does not mention: if the original address dies while its redirect target still serves, Refresh now fails where it previously succeeded. That is the correct call — display is what the user asked for — but it is a behaviour change with a losing side.

The deferral is safe in both scheduling orders, and only one of them is the new guard's doing

The change's headline claim — one page load per refresh — rests on an ordering that is scheduled, not sequenced. Two things are queued when updateRemoteBase fires its willSet: the synchronizer's Task { @MainActor } pass, and SwiftUI's re-keyed .task(id:) load. Neither is ordered against the other.

It holds anyway, by two different mechanisms:

  • Pass first. controller.parseRevision is still the old revision, the new guard defers, and the owed load then fetches against the already-written box. One load.
  • Load first. loadDocument calls controller.beginLoad() synchronously before its await prepareHTML, raising isSuperseded; reloadDocument's pre-existing guard !controller.isSuperseded then skips the synchronizer's reload. One load again — via the older guard, not the new one.

So the property is real but over-attributed. The new guard's genuine contribution is the window after the load has issued and before the pass runs, where isSuperseded has been cleared again by load — there the revision comparison is the only thing standing between the reader and a second reload that discards the first's scroll restore.

controller.parseRevision is assigned only from session.parseRevision, in WebDocumentController.load, whose only callers are loadDocument/reloadDocument. It is therefore always equal-or-behind, so != means “behind” and nothing else. The > case is unreachable because computePassdispatch is fully synchronous.

Observation cost is genuinely zero

SyncPass.parseRevision looks like a new dependency but is not: mapping(for:) already reads session.parseRevision on its first line inside the same tracked read, as its cache key. Pass frequency is unchanged. The field is also strictly derivable — dispatch holds session and runs with no suspension after computePass, so pass.parseRevision can never disagree with session.parseRevision at the comparison site. Defensible as intent documentation; it is redundant state today.

The base is now network-derived, which moves a load-bearing invariant

httpResponse.url is trustworthy here because ChunkedBodyLoader.urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:) rejects any target that is not http(s) or that carries userinfo, and the delegate is installed on a URLSessionDataDelegate, so the callback genuinely fires. A rejected redirect resolves through didCompleteWithError as an httpError or URLError(.unknown) — never as a LoadResult.

That single gate is now doing more work than it was. LinkPathResolver.classifyRemote carries its own userinfo check specifically because a .url source's base “could carry userinfo”; ImagePathResolver has the equivalent gate only on the absolute branch, and none on the relative one. Nothing is broken — the loader's check covers it — but the relative-image path is now safe by a dependency it does not name, where previously the base was user-typed and separately validated. A mirrored guard or a comment naming the dependency would keep that legible.

Two pre-existing edges this widens

Directory-URL bases are off by one. imageBaseURL is remote.deletingLastPathComponent(). For a final URL ending in / — a 302 to https://cdn.example.com/docs/v2/ serving markdown — that yields .../docs/ where RFC 3986 relative resolution gives .../docs/v2/. Reachable before by typing such a URL; redirects to directory URLs make it materially more likely.

The content-type gate keys on the pre-redirect URL. MarkdownFileExtensions.isAccepted(transformed.fetchURL) still tests the requested address, so https://trusted/doc.md → 302 → https://attacker/evil.html serving text/html skips the content-type check entirely. Pre-existing since T-432 and out of scope, but this change sharpens the asymmetry: the final URL is now what the app trusts as the resource base while the gate still trusts the requested one. Computing the effective URL once, above line 213, and using it in both places would close it.

Supersession reasoning is sound and slightly over-built

parseGeneration is bumped synchronously at entry, so only the last-started parse can apply, and content is assigned in the same call order — the applied parse and the surviving content always belong to the same call. The shape that could desynchronise (a different re-parse path superseding a refresh's parse, keeping its content but dropping its base) is unreachable for .url sessions: reloadContent(from:) is file-only and .url sessions get no FileChangeObserver, while the refresh path is additionally serialised by currentRequestID. The false return is defensive rather than load-bearing — which is the right way round, and the test that pins it is a real test: remove the guard and it fails.

Important changes — detailed

URLDocumentLoader: report the final response URL, not the request URL

prism/Services/URLDocumentLoader.swift

Why it matters. The whole defect in one expression. `httpResponse.url` was already in scope — used for the status and content-type checks — and then discarded in favour of `transformed.fetchURL` at the return statement. Everything downstream was correct given the value it was handed; it was handed the wrong one.

What to look at. URLDocumentLoader.swift:230-244 (LoadResult construction)

Takeaway. When a network layer holds both a requested and a final URL, the return statement is where they get confused. Name and document both at the point they coexist — the doc comments this PR adds to `LoadResult` are the durable half of the fix.
Rationale. Minimal and local: fix the value at its source rather than plumbing the final URL through every read site. `DocumentSource.imageBaseURL` and `DocumentFlowCoordinator.openRemoteSession` needed no change at all.

RemoteRefreshFlow: refresh the display URL, not the stored remote

prism/ViewModels/RemoteRefreshFlow.swift

Why it matters. This is the finding the previous review raised, and the one with the most consequence. Once `remote` is a redirect's destination, re-fetching it pins the document to that destination permanently: a re-pointed `latest.md` alias never re-resolves, and a 302 to a short-lived signed CDN URL makes Refresh fail outright once the signature expires. Fetching `display` also re-runs the GitHub blob→raw transform and the scheme/credential validation that the old path deliberately skipped.

What to look at. RemoteRefreshFlow.swift:86 (the guard binding) and :113-134 (the load call)

Takeaway. Changing what a stored field *means* silently changes every read of it. The guard binding at line 86 is the read that flipped from correct to wrong without its type or name changing — grep for reads, not for compile errors.
Rationale. Recorded as Decision Q2. `display` is the user's stated intent; `remote` is a cached consequence of it. Re-deriving the consequence on each refresh is the only shape where a moved alias is picked up.

reloadContent applies the base with the parse, and a superseded parse applies neither

prism/Models/DocumentSession.swift

Why it matters. Ordering is the whole content of this change. The base must land in the same synchronous stretch as the `parseRevision` bump so the synchronizer's coalescing pass sees one change, not two. `parseAndApplyBlocks` gains a `Bool` so a parse superseded under T-718 drops its base too — otherwise a stale refresh resuming late writes its redirect target over the winning refresh's.

What to look at. DocumentSession.swift:748-778 (reloadContent) and :789-830 (parseAndApplyBlocks)

Takeaway. Applying the base *before* the parse — the obvious-looking alternative — is actively worse: the observation pass would fire during the parse's suspension and reload the page at the old revision. With Observation-driven consumers, 'same synchronous stretch' is a real API contract, not an implementation detail.
Rationale. Stated in the commit message and the method's doc comment, and pinned by `supersededReloadAppliesNoRemoteBase`.

Synchronizer defers its image reload when the page is behind the session

prism/ViewModels/WebDocumentStateSynchronizer.swift

Why it matters. Without it a refresh whose redirect target moved costs two page loads, and the second discards the first's scroll restore — the reader loses their place on every refresh of a redirecting document. The comparison's direction is the subtle part, and both directions are pinned by tests.

What to look at. WebDocumentStateSynchronizer.swift:178-181, :243, :316-332

Takeaway. `controller.parseRevision` is assigned only from `session.parseRevision`, so it is structurally equal-or-behind and `!=` can only mean 'behind'. That is what makes a bare `!=` sufficient where a `<` would read as more careful and say the same thing.
Rationale. Reasoned in the inline comment and the bugfix report. Note the property is over-attributed: in the opposite scheduling order the pre-existing `isSuperseded` guard in `reloadDocument` already prevents the second load. (inferred — not stated by the author)

Test coverage: four layers, and both directions of the new guard

prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift

Why it matters. The previous review's third finding was a missing end-to-end test. The PR adds one at each layer — loader, session, flow, and the production web assembly — and, crucially, pins the deferral guard in *both* directions. A one-sided test would have let the T-1784 clipboard-save path regress silently.

What to look at. WebStateSynchronizerAssemblyTests.swift:391-455; RemoteContentCoordinatorTests.swift:163-194

Takeaway. `remoteBaseChangeWithPageCurrentReloads` is the test that earns its keep — it guards the branch the change did *not* intend to alter. When you add a condition to an existing unconditional action, test the condition being false, not just true.
Rationale. The suite already carries `@Suite(.liveWebKit) @MainActor` and both new tests are `async`, so `make verify-test-isolation` passes — confirmed by running it.

Key decisions

`remote` becomes 'served from' rather than 'fetched from'.

Recorded as Quick Decision Q2 in specs/open-from-url/decision_log.md. display remains identity; remote is now the post-redirect response URL and the base for relative resolution.

Tier objection: the project's own decision-log-format.md reserves the table for resolutions where you cannot name a genuine alternative or a meaningful consequence. This change fails that test on both counts — the PR's own bugfix report names an alternative explicitly (resolve at usage time in ImagePathResolver/LinkPathResolver rather than moving the stored base), the review history supplies a second (apply the base before vs. with the parse), and there are unrecorded consequences: the base is now redirect-controlled, every refresh pays the hop again, and a refresh re-runs validation the old path skipped. Q2's prose is longer than several full ADRs in the same file, which is itself the tell.

Decision 1 is amended but not marked as such.

decision_log.md Decision 1 is titled “Use Display URL for Identity, Fetch URL for Downloading” and states the remote URL is used “only for fetching content” — which is now exactly backwards, since remote is the one URL a refresh never fetches. Q2 says it “refines” Decision 1, but Decision 1 is left at Status: accepted with no pointer forward. The format file's promotion path (full entry, plus a superseded in part by note on the original) is the shape this wants.

Refresh re-fetches `display`, accepting an extra redirect hop.

One extra round trip per manual Refresh, uncacheable because URLDocumentLoader.load builds a fresh ephemeral URLSession per call. Correct trade: the alternative is a permanently pinned document. Undocumented losing side: if the original address later dies while its redirect target still serves, Refresh now fails where it previously succeeded. Worth a line in the bugfix report's trade-off section.

(inferred — not stated by the author.)
The base is applied after the parse, not before.

Explicitly reasoned in the commit message and in reloadContent's doc comment: applying it first would let the synchronizer's pass run during the parse's suspension and reload the page at the old revision. This is the kind of rationale that is invisible from the diff and would have been re-derived painfully later — good that it is written down at the method.

Review findings

SeverityAreaFindingResolution
majorspecs/open-from-url/requirements.md:66 (Req 4.1)A written SHALL now contradicted by the shipped code: "`remote` is the URL used for fetching (possibly transformed)". `remote` is now the final response URL, and it is the one URL a refresh never fetches. The previous review's finding was "undocumented design shift"; the commit documented it in the CHANGELOG, the agent note, the bugfix report and Decision Q2 — but not in the requirement that states the old contract normatively.Amend Req 4.1 to define `remote` as the URL the content was served from (post-redirect), used as the base for relative resources, and `display` as the address identity and refresh both use. Read-only review: not applied.
majorCLAUDE.md:53 (Serve / synchronizer invariant)States unconditionally that for an image-context change the synchronizer "writes the box and issues a same-revision `reloadDocument`", with "its first pass only seeds" as the sole exemption. This PR adds a second exemption (`!isSeed && controller.parseRevision == pass.parseRevision`), so a base change riding a re-parse now issues no reload at all. This is precisely the class of non-obvious ordering rule CLAUDE.md exists to carry, and it is currently recorded only in the source comment and the bugfix report.Add the revision-comparison exemption to that sentence. Read-only review: not applied.
minorprism/Models/DocumentSession.swift:438-439 (inside the diff)`updateRemoteBase`'s doc comment says "A refresh (`RemoteRefreshFlow`) re-downloads from this session's current remote URL" — the behaviour commit 0de4e02d removed. It contradicts its own sibling docs (`DocumentSource.swift:36-38`), the call site (`RemoteRefreshFlow.swift:113-121`) and Decision Q2. Introduced by 694a6a3a and not swept when 0de4e02d changed the refresh target. In a codebase this comment-dependent, a doc asserting the rejected behaviour is the most expensive kind of stale.Reword to "a refresh re-downloads from `display`, and the server can redirect that request to a different final URL...". Two lines. This is the one I would take before pushing. Read-only review: not applied.
minorspecs/open-from-url/design.md:545-547The Refresh Button snippet binds `if case .url(let remote, _)` and calls `refreshFromURL(remote)` — now the exact behaviour Q2 rules out. Already shape-stale from T-1805, but this PR makes it semantically wrong rather than merely outdated. Signature drift too: design.md:205, :569, :583, :615 still name `reloadContent(markdownString:)`.One-line fix to the snippet (`case .url(_, let display)`) plus the signature sweep. Read-only review: not applied.
minorspecs/open-from-url/decision_log.md (Q2 tier)Q2 belongs in a full Enhanced Nygard entry by the project's own rule — genuine alternatives and meaningful consequences both exist and are named in the PR's own report. Decision 1 should gain a `superseded in part by` pointer.Promote Q2 to a full entry; annotate Decision 1. Read-only review: not applied.
minorprism/Services/URLDocumentLoader.swift:213 (content-type gate)The extension gate still keys on the pre-redirect `transformed.fetchURL`, so `https://trusted/doc.md` → 302 → `https://attacker/evil.html` serving `text/html` skips the content-type check entirely. Pre-existing since T-432, and the strict direction never over-rejects — but this change sharpens the asymmetry, because the final URL is now what the app trusts as the resource base while the gate trusts the requested one.Out of scope for T-1810. Worth a follow-up ticket: compute the effective URL once above line 213 and use it for both the gate and the LoadResult.
minorprism/Services/ImagePathResolver.swift (relative branch, userinfo)`LinkPathResolver.classifyRemote` carries an explicit userinfo gate justified by a `.url` source's base "possibly carrying userinfo"; `ImagePathResolver` has the equivalent only on the absolute branch (:273-278), none on the relative one. Safe today solely because the loader credential-checks the initial URL and every redirect hop — but that base is now network-derived rather than user-typed, so the relative-image path is safe by an unnamed dependency.Mirror the guard, or add a comment naming the loader's redirect validation as the enforcement point. Read-only review: not applied.
minorprism/Models/DocumentSource.swift:157-159 (imageBaseURL)`remote.deletingLastPathComponent()` is off by one directory when the final URL ends in `/`: a 302 to `https://cdn.example.com/docs/v2/` serving markdown yields base `.../docs/` where RFC 3986 gives `.../docs/v2/`. Reachable before by typing such a URL; redirects to directory URLs make it materially more likely.Pre-existing, out of scope. Worth a follow-up ticket. Read-only review: not applied.
minorprismTests/ (coverage gaps)Two cheap assertions missing. (1) No test composes 'refresh passes `display`' with '`load` re-applies the GitHub blob→raw transform', even though refresh correctness now depends on that composition — the old code skipped the transform deliberately. (2) Nothing asserts the recents/notes key stays on `display` when `display != remote`; `successfulRefreshUpdatesRecentTitle` constructs the session with `remoteURL: displayURL`, so the divergent case — including the `updateTitle(forRemoteURL: display, …)` line this PR changed — is untested.Add a blob-URL refresh test over a MockURLProtocol-backed loader, and seed a recents entry under `displayURL` in `refreshUpdatesRemoteBaseOnRedirectChange`. Read-only review: not applied.
nitprismTests/RemoteRefreshFlowTests.swift:665-682The new `RequestedURLs` helper (`@unchecked Sendable` over `NSLock`) duplicates the existing `SpikeRequestLog` (`prismTests/WebRenderingSpikes/SpikeWebPageHarness.swift:21-31`) — same method name, same property name, same semantics, and the existing one is better: real `Sendable` over `Mutex`. It is `internal` and already used across directories in the same target, so it is directly reusable.Reuse `SpikeRequestLog`, or move/rename it to `prismTests/Support/`. Read-only review: not applied.
nitprismTests/RemoteRefreshFlowTests.swift:407-409The comment claims the test "also verifies `updateRemoteBase` is a no-op rather than churning `source`". It does not — `source` is `Equatable` and the value is identical, so a redundant write is indistinguishable from a skip by any assertion present.Drop the claim or assert an observation count. Read-only review: not applied.
nitprism/ViewModels/WebDocumentStateSynchronizer.swift:183/243`SyncPass.parseRevision` is derivable: `dispatch` runs synchronously after `computePass` with no suspension and already holds `session`, so `pass.parseRevision` can never disagree with `session.parseRevision` at the comparison site. It also buys no tracking — `mapping(for:)` already registers `session.parseRevision` inside the same tracked read.Defensible as intent documentation for a future deferred `dispatch`. Left as-is.
nitspecs/bugfixes/redirect-final-url-base/report.md (trade-offs)The report does not record the losing side of refreshing from `display`: if the original address dies while its redirect target still serves, Refresh now fails where it previously succeeded.One sentence in the trade-off discussion. Read-only review: not applied.

Tests

Source: local run at 2026-09-06T02:12:42+10:00 · snapshot 0de4e02d166e82828efdc08286c32acd988da967

Baseline: none

Execution: failed · 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 2b10c6dc4944d8e50c99e6b60e962591a230b39f.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..4f6998cb 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- A document opened from a URL that redirects now resolves its relative images and links against the address the content was actually served from, not the one you typed (T-1810). A version alias, a shortened link or a page that has moved to another folder or host sends the request on to its final address, and the app kept the original one as the base for everything relative in the document, so every image beside it was looked up in the wrong place and showed the error placeholder. The address shown in the title, in recents and in the notes store is unchanged: it stays the one you opened. Refreshing such a document asks for the address you opened again — not for wherever it redirected to last time — and follows the redirect afresh, so an alias that has been re-pointed since the document was opened picks up its new target, and the image base moves with it; the refresh costs one page reload, not one for the new content and another for the new base. - 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.
docs/agent-notes/open-from-url.md Modified +4 / -3
diff --git a/docs/agent-notes/open-from-url.md b/docs/agent-notes/open-from-url.mdindex b09e9717..c4d321b1 100644--- a/docs/agent-notes/open-from-url.md+++ b/docs/agent-notes/open-from-url.md@@ -5,13 +5,14 @@ The feature adds a fourth document source (`.url(remote:display:)`) alongside `.file`, `.clipboard`, and `.bundled`. The flow:  1. User provides URL → `GitHubURLTransformer` converts GitHub blob URLs to raw URLs-2. `URLDocumentLoader` downloads via ephemeral URLSession with streaming (10MB limit)-3. `DocumentSession(remoteURL:displayURL:content:)` creates session+2. `URLDocumentLoader` downloads via ephemeral URLSession with streaming (10MB limit); `LoadResult.fetchURL` is the response's final URL, after redirects+3. `DocumentSession(remoteURL:displayURL:content:)` creates session, with that final URL as `remote` 4. Same rendering pipeline as all other sources  ## Key Design Decisions -- **Two URLs stored**: `remote` (fetch URL, possibly transformed) and `display` (user-provided, used for identity/notes)+- **Two URLs stored**: `remote` (the FINAL URL the content was served from — transformed fetch URL after redirects, T-1810; relative images/links resolve against it) and `display` (user-provided, used for identity/notes, never changes)+- **Refresh re-fetches `display`, not `remote`**: `remote` is the previous redirect's target, so fetching it again would never re-resolve a re-pointed alias and would fail once a short-lived signed URL expired. `RemoteRefreshFlow` loads `display` (the loader re-applies the GitHub transform and re-validates) and hands the new final URL to `DocumentSession.reloadContent(markdownString:remoteBase:)`, which applies it in the same turn as the parse so `WebDocumentStateSynchronizer` sees the new blocks, revision and image base in one pass and leaves re-fetching images to the revision load (one page reload per refresh, not two). Decision Q2 in `specs/open-from-url/decision_log.md`. - **Download before session creation**: Content must be available at `DocumentSession` init time (Decision 6) - **`content` is `private(set) var`**: Changed from `let` to support URL refresh and fix stale content after file reload (Decision 7) - **Streaming download**: `URLSession.bytes(from:)` to enforce size limit without loading oversized responses into memory (Decision 8)
prism/Models/DocumentSession.swift Modified +55 / -7
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex 0ed81d73..1dd0c1ec 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -417,8 +417,11 @@ final class DocumentSession: Identifiable {     /// No `FileChangeObserver` is created — the Refresh button handles re-fetching.     ///     /// - Parameters:-    ///   - remoteURL: The URL used for fetching (possibly transformed).-    ///   - displayURL: The original URL the user provided.+    ///   - remoteURL: The final URL the content was served from — the transformed+    ///     fetch URL after any redirects (T-1810). Relative resources resolve+    ///     against it; a refresh replaces it via `updateRemoteBase(to:)`.+    ///   - displayURL: The original URL the user provided. Identity, display,+    ///     and what a refresh re-fetches.     ///   - content: The downloaded markdown content.     init(remoteURL: URL, displayURL: URL, content: String) {         self.id = UUID()@@ -429,6 +432,27 @@ final class DocumentSession: Identifiable {         wireSearchClosures()     } +    /// Updates the remote fetch base after a URL refresh resolves to a new+    /// final URL.+    ///+    /// A refresh (`RemoteRefreshFlow`) re-downloads from this session's+    /// current remote URL, but the server can redirect that request to a+    /// DIFFERENT final URL than the one this session was opened with — e.g.+    /// a version alias or CDN endpoint that has since moved. Relative+    /// images/links must resolve against that new final URL, so this updates+    /// the `.url` source's `remote` component in place while preserving+    /// `displayURL`, the user's original address, which never changes+    /// (T-1810). No-op for any other source, and a no-op when the base has+    /// not changed.+    ///+    /// The refresh path applies it through `reloadContent(markdownString:remoteBase:)`+    /// rather than calling this directly, so the base lands in the same turn as+    /// the parse it belongs to — see there for why that ordering is load-bearing.+    func updateRemoteBase(to remoteURL: URL) {+        guard case .url(let currentRemote, let displayURL) = source, currentRemote != remoteURL else { return }+        source = .url(remote: remoteURL, display: displayURL)+    }+     /// Whether this session has unsaved content.     ///     /// Returns true for clipboard sources, false for file sources.@@ -721,12 +745,32 @@ final class DocumentSession: Identifiable {     /// Updates `content`, resets expansion state, and re-parses blocks.     /// Notes are preserved since the document identifier hasn't changed.     ///-    /// - Parameter markdownString: The new markdown content.-    func reloadContent(markdownString: String) async {+    /// `remoteBase` is the final URL the refetch was served from, applied via+    /// `updateRemoteBase(to:)` in the SAME synchronous stretch as the parse+    /// result — not by the caller afterwards (T-1810 review). The new blocks,+    /// the revision bump and the new base then land in one main-actor turn, so+    /// `WebDocumentStateSynchronizer`'s single coalesced pass sees all three+    /// together and can leave re-fetching the images to the revision load the+    /// document surface already owes, instead of issuing a same-revision reload+    /// of its own for the base change — two page loads for one refresh.+    /// Applying the base BEFORE the parse would be worse still: the pass would+    /// run during the parse's suspension and reload the page at the OLD revision.+    /// A parse superseded by a newer one applies nothing, base included — the+    /// newer reload carries its own.+    ///+    /// - Parameters:+    ///   - markdownString: The new markdown content.+    ///   - remoteBase: The final URL the content was served from, for a `.url`+    ///     source whose redirect target may have moved; `nil` leaves the source+    ///     untouched.+    func reloadContent(markdownString: String, remoteBase: URL? = nil) async {         content = markdownString         expansionCoordinator.reset()         tableDisplayModes.removeAll()-        await parseAndApplyBlocks(from: markdownString)+        guard await parseAndApplyBlocks(from: markdownString) else { return }+        if let remoteBase {+            updateRemoteBase(to: remoteBase)+        }     }      /// Shared pipeline for parsing markdown content and applying the results.@@ -745,7 +789,10 @@ final class DocumentSession: Identifiable {     /// If a newer parse started during suspension, the stale results are discarded.     ///     /// - Parameter content: The raw markdown string to parse.-    private func parseAndApplyBlocks(from content: String) async {+    /// - Returns: `true` when this parse's results were applied; `false` when a+    ///   newer parse superseded it and its results were discarded.+    @discardableResult+    private func parseAndApplyBlocks(from content: String) async -> Bool {         // Capture the current generation so we can detect stale results after await.         parseGeneration &+= 1         let myGeneration = parseGeneration@@ -762,7 +809,7 @@ final class DocumentSession: Identifiable {         let (taggedRanges, parseResult) = await (taggedRangesTask, parseResultTask)          // T-718: Discard results if a newer parse started while we were suspended.-        guard myGeneration == parseGeneration else { return }+        guard myGeneration == parseGeneration else { return false }          // Store footnote data from the parse result         footnoteData = parseResult.footnoteData@@ -779,6 +826,7 @@ final class DocumentSession: Identifiable {         // T-987: Signal a successful parse so observers (notes pipeline)         // can resync against the new blocks/imported notes/tagged ranges.         parseRevision &+= 1+        return true     }      /// Extracts the document title from parsed blocks.
prism/Models/DocumentSource.swift Modified +8 / -2
diff --git a/prism/Models/DocumentSource.swift b/prism/Models/DocumentSource.swiftindex 9cb3461c..9f0f8828 100644--- a/prism/Models/DocumentSource.swift+++ b/prism/Models/DocumentSource.swift@@ -30,8 +30,14 @@ enum DocumentSource: Equatable {     /// Content downloaded from a remote URL.     ///     /// - Parameters:-    ///   - remote: The URL used for fetching (possibly transformed, e.g. raw.githubusercontent.com).-    ///   - display: The original URL the user provided (used for identity and display).+    ///   - remote: The final URL the content was actually served from — the+    ///     transformed fetch URL (e.g. raw.githubusercontent.com) after any HTTP+    ///     redirects (T-1810). Relative images and links resolve against it. A+    ///     refresh does NOT fetch it again: it re-fetches `display`, so a+    ///     re-pointed alias resolves afresh, then replaces this with the new+    ///     final URL (`DocumentSession.updateRemoteBase(to:)`).+    ///   - display: The original URL the user provided (used for identity and+    ///     display); never changes for the life of the session.     case url(remote: URL, display: URL)      /// Whether this source represents unsaved content.
prism/Services/URLDocumentLoader.swift Modified +19 / -3
diff --git a/prism/Services/URLDocumentLoader.swift b/prism/Services/URLDocumentLoader.swiftindex 1bb39c06..ebac2a23 100644--- a/prism/Services/URLDocumentLoader.swift+++ b/prism/Services/URLDocumentLoader.swift@@ -113,9 +113,17 @@ nonisolated enum URLDocumentLoader {     struct LoadResult {         /// The downloaded markdown content.         let content: String-        /// The URL used for fetching (possibly transformed from GitHub blob).+        /// The URL that actually served the content: the final response URL+        /// after following any HTTP redirects (and, before that, the GitHub+        /// blob → raw transform). This is the correct base for resolving+        /// relative images/links in the document — a redirect can move the+        /// document to a different host or path than the one originally+        /// requested (T-1810), and resolving against the pre-redirect URL+        /// breaks those relative resources.         let fetchURL: URL-        /// The original URL as provided by the user.+        /// The original URL as provided by the user. Never changed by a+        /// redirect — used for display and identity (recents, notes, window+        /// titles).         let displayURL: URL     } @@ -219,9 +227,17 @@ nonisolated enum URLDocumentLoader {             throw LoadError.encodingError         } +        // Use the final, post-redirect response URL as the fetch/base URL+        // (T-1810) rather than the pre-redirect request URL: a redirect can+        // land the document on a different host or path, and relative+        // images/links must resolve against where the content actually came+        // from. `httpResponse.url` is nil only when the response was+        // constructed without one, which `URLSession` never does for a real+        // network response — `transformed.fetchURL` is kept as a defensive+        // fallback for that case.         return LoadResult(             content: content,-            fetchURL: transformed.fetchURL,+            fetchURL: httpResponse.url ?? transformed.fetchURL,             displayURL: transformed.displayURL         )     }
prism/ViewModels/RemoteRefreshFlow.swift Modified +22 / -8
diff --git a/prism/ViewModels/RemoteRefreshFlow.swift b/prism/ViewModels/RemoteRefreshFlow.swiftindex 3ecc6c22..839d00ba 100644--- a/prism/ViewModels/RemoteRefreshFlow.swift+++ b/prism/ViewModels/RemoteRefreshFlow.swift@@ -83,7 +83,7 @@ final class RemoteRefreshFlow {     /// 5.4) and refreshes the cached recents title. On failure, records an     /// error message and retains existing content (Req 5.5).     func refresh(session: DocumentSession, recentFilesManager: RecentFilesManager) {-        guard case .url(let remote, _) = session.source else { return }+        guard case .url(_, let display) = session.source else { return }          // Cancel any in-flight refresh before starting a new one.         task?.cancel()@@ -110,20 +110,34 @@ final class RemoteRefreshFlow {              do {                 guard let loader = self?.loader else { return }-                // `remote` is already the fetch URL (e.g., raw.githubusercontent.com),-                // so GitHubURLTransformer inside load() will pass it through unchanged.-                let result = try await loader(remote)+                // Refresh from the DISPLAY URL — the address the user opened — not from+                // the stored `remote`. Since T-1810 `remote` is the FINAL URL the last+                // fetch was served from, so fetching it again would follow the previous+                // redirect's target instead of re-resolving the user's address: a+                // re-pointed `latest.md` alias would keep serving the old target, and a+                // 302 to a short-lived signed CDN URL would make Refresh fail once it+                // expired. `URLDocumentLoader.load` re-applies the GitHub blob→raw+                // transform and re-validates scheme/credentials on the way, exactly as+                // the initial open did.+                let result = try await loader(display)                  guard let self, !Task.isCancelled, self.currentRequestID == requestID else { return } -                await session.reloadContent(markdownString: result.content)+                // T-1810: the refetch can have redirected to a different final+                // URL than the one this session was opened with (e.g. a moved+                // CDN target). The final URL rides along as the new remote base+                // so relative images/links keep resolving correctly; `displayURL`+                // is left untouched. It is handed to `reloadContent` rather than+                // applied here afterwards so the base lands in the same turn as+                // the parse — one page reload for the refresh, not two (see+                // `DocumentSession.reloadContent(markdownString:remoteBase:)`).+                await session.reloadContent(markdownString: result.content, remoteBase: result.fetchURL)                  guard !Task.isCancelled, self.currentRequestID == requestID else { return }                  // Update cached title in recent files in case the document title changed.-                if case .url(_, let displayURL) = session.source {-                    recentFilesManager.updateTitle(forRemoteURL: displayURL, title: session.cachedDocumentTitle)-                }+                // `display` is still the session's display URL: a base update never moves it.+                recentFilesManager.updateTitle(forRemoteURL: display, title: session.cachedDocumentTitle)             } catch is CancellationError {                 // Unreachable with the production loader: `URLDocumentLoader.load`                 // wraps every non-`LoadError` — including `URLError.cancelled` and
prism/ViewModels/WebDocumentStateSynchronizer.swift Modified +15 / -1
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex 2d3138c9..707cabbb 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -178,6 +178,9 @@ final class WebDocumentStateSynchronizer {         /// Where the page must resolve images from (T-1784). Not a bridge push: it is         /// written into the scheme handler's live box and re-requested by a reload.         var imageSource: DocumentImageSourceContext+        /// The session revision the pass was computed for: decides whether an+        /// image-source change needs a reload of its own (see `dispatch`).+        var parseRevision: UInt64         var anchorTarget: String?         var noteNavigationTarget: String?         /// Navigation context, not desired state: the pass's single blocks→DOM id@@ -237,6 +240,7 @@ final class WebDocumentStateSynchronizer {             detailsOpenDOMIDs: session.expansionCoordinator.openDetailsDOMIDs,             tableModes: translatedTableModes(mapped: mapped),             imageSource: session.source.imageSourceContext,+            parseRevision: session.parseRevision,             anchorTarget: session.pendingAnchorScroll,             noteNavigationTarget: coordinator.noteNavigationTarget,             mapped: mapped@@ -312,7 +316,17 @@ final class WebDocumentStateSynchronizer {             // stays invisible to the reader. The first pass runs before the initial             // load, so it has nothing to re-request — and reloading there would fight             // the load the document surface is about to issue.-            if !isSeed {+            //+            // The same applies whenever the page on screen is BEHIND the session+            // (T-1810 review): a base change that lands in the same turn as a re-parse+            // — a URL refresh whose redirect target moved — has already re-keyed the+            // surface's load task, and that revision load fetches every image against+            // the context written above. Reloading here as well would bring up a page+            // that load is about to replace: two reloads for one refresh, the second+            // discarding the first's scroll restore. The revision comparison is what+            // makes the deferral safe — if the surface's load has ALREADY happened, the+            // page is current and did fetch against the old context, so it reloads.+            if !isSeed && controller.parseRevision == pass.parseRevision {                 _ = WebDocumentControllerFactory.reloadDocument(                     controller: controller, session: session                 )
prismTests/DocumentSessionRemoteBaseTests.swift Added +113 / -0
diff --git a/prismTests/DocumentSessionRemoteBaseTests.swift b/prismTests/DocumentSessionRemoteBaseTests.swiftnew file mode 100644index 00000000..10995e7f--- /dev/null+++ b/prismTests/DocumentSessionRemoteBaseTests.swift@@ -0,0 +1,113 @@+//+//  DocumentSessionRemoteBaseTests.swift+//  prismTests+//+//  Regression tests for T-1810: a redirected remote document must resolve+//  relative resources against the final (post-redirect) URL, not the one it+//  was originally opened with.+//++import Foundation+import Testing+@testable import prism++/// Tests for `DocumentSession.updateRemoteBase(to:)`, which lets a URL+/// refresh move the session's remote fetch base in place when the server+/// redirects the refetch to a different final URL than the one the session+/// was opened with.+@Suite("DocumentSession.updateRemoteBase (T-1810)")+struct DocumentSessionRemoteBaseTests {++    @Test("updateRemoteBase moves the remote component while preserving displayURL")+    @MainActor+    func updateRemoteBaseMovesRemoteKeepsDisplay() {+        let remote = URL(string: "https://a.example.com/latest.md")!+        let display = URL(string: "https://a.example.com/latest.md")!+        let newRemote = URL(string: "https://cdn.example.com/docs/v2/readme.md")!++        let session = DocumentSession(remoteURL: remote, displayURL: display, content: "# Hello")+        session.updateRemoteBase(to: newRemote)++        #expect(session.source == .url(remote: newRemote, display: display))+        #expect(session.source.imageBaseURL == URL(string: "https://cdn.example.com/docs/v2/")!)+    }++    @Test("updateRemoteBase is a no-op for a non-URL source")+    @MainActor+    func updateRemoteBaseNoOpForNonURLSource() {+        let session = DocumentSession(clipboardContent: "# Clipboard")+        let originalSource = session.source++        session.updateRemoteBase(to: URL(string: "https://example.com/new.md")!)++        #expect(session.source == originalSource)+    }++    @Test("updateRemoteBase is a no-op when the remote URL is unchanged")+    @MainActor+    func updateRemoteBaseNoOpWhenUnchanged() {+        let remote = URL(string: "https://example.com/doc.md")!+        let session = DocumentSession(remoteURL: remote, displayURL: remote, content: "# Hello")++        session.updateRemoteBase(to: remote)++        #expect(session.source == .url(remote: remote, display: remote))+    }++    // MARK: - reloadContent(markdownString:remoteBase:)++    /// The refresh path hands the final URL to `reloadContent` so the base is+    /// applied with the parse result, not by the caller afterwards (review of+    /// T-1810: one page reload for the refresh, not two).+    @Test("reloadContent applies the remote base together with the parse result")+    @MainActor+    func reloadContentAppliesRemoteBaseWithParse() async {+        let remote = URL(string: "https://a.example.com/latest.md")!+        let moved = URL(string: "https://cdn.example.com/docs/v2/readme.md")!+        let session = DocumentSession(remoteURL: remote, displayURL: remote, content: "# Hello")+        await session.parseContent()+        let revisionBefore = session.parseRevision++        await session.reloadContent(markdownString: "# Moved", remoteBase: moved)++        #expect(session.source == .url(remote: moved, display: remote))+        #expect(session.parseRevision == revisionBefore + 1)+        #expect(session.cachedDocumentTitle == "Moved")+    }++    @Test("reloadContent without a remote base leaves the source untouched")+    @MainActor+    func reloadContentWithoutRemoteBaseLeavesSource() async {+        let remote = URL(string: "https://a.example.com/latest.md")!+        let session = DocumentSession(remoteURL: remote, displayURL: remote, content: "# Hello")+        await session.parseContent()++        await session.reloadContent(markdownString: "# Same place")++        #expect(session.source == .url(remote: remote, display: remote))+        #expect(session.cachedDocumentTitle == "Same place")+    }++    /// A reload superseded by a newer one (T-718) applies nothing — its base+    /// included, or a stale refresh could move the session onto a redirect+    /// target the winning refresh never saw.+    @Test("A superseded reload applies neither its content nor its remote base")+    @MainActor+    func supersededReloadAppliesNoRemoteBase() async {+        let remote = URL(string: "https://a.example.com/latest.md")!+        let staleBase = URL(string: "https://stale.example.com/old.md")!+        let latestBase = URL(string: "https://cdn.example.com/docs/v2/readme.md")!+        let session = DocumentSession(remoteURL: remote, displayURL: remote, content: "# Hello")+        await session.parseContent()++        // One yield puts the stale reload genuinely in flight before the latest+        // one starts (see `beginReload` in DocumentSessionParseGenerationTests).+        let stale = Task { await session.reloadContent(markdownString: "# Stale", remoteBase: staleBase) }+        await Task.yield()+        await session.reloadContent(markdownString: "# Latest", remoteBase: latestBase)+        await stale.value++        #expect(session.source == .url(remote: latestBase, display: remote))+        #expect(session.cachedDocumentTitle == "Latest")+    }+}
prismTests/RemoteContentCoordinatorTests.swift Modified +33 / -0
diff --git a/prismTests/RemoteContentCoordinatorTests.swift b/prismTests/RemoteContentCoordinatorTests.swiftindex fb7e53d8..56ef8203 100644--- a/prismTests/RemoteContentCoordinatorTests.swift+++ b/prismTests/RemoteContentCoordinatorTests.swift@@ -159,6 +159,39 @@ struct RemoteContentCoordinatorTests {         #expect(coordinator.downloadTask == nil)         #expect(flow.loadError == nil)     }++    /// The reported path for T-1810, end to end: opening an address that+    /// redirects must leave the session resolving relative images against the+    /// redirect's target, while the document's identity stays the address the+    /// user opened.+    @Test("Opening a redirected URL resolves images against the final URL (T-1810)")+    func openingRedirectedURLResolvesImagesAgainstFinalURL() async throws {+        let requested = URL(string: "https://a.example.com/latest.md")!+        let redirectTarget = URL(string: "https://cdn.example.com/docs/v2/readme.md")!+        // What `URLDocumentLoader.load` reports for a request that redirected:+        // `fetchURL` is the response's final URL, `displayURL` the one asked for.+        let loader: RemoteContentCoordinator.Loader = { _ in+            URLDocumentLoader.LoadResult(+                content: "# Moved\n\n![diagram](diagram.png)",+                fetchURL: redirectTarget,+                displayURL: requested+            )+        }++        let coordinator = RemoteContentCoordinator(loader: loader)+        let flow = DocumentFlowCoordinator()++        coordinator.openRemoteURL(requested, flowCoordinator: flow)+        await coordinator.downloadTask?.value++        let session = try #require(flow.currentSession)+        #expect(session.source == .url(remote: redirectTarget, display: requested))+        #expect(+            session.source.imageBaseURL == URL(string: "https://cdn.example.com/docs/v2/")!,+            "relative images must resolve against where the content was served from, not the address typed"+        )+        #expect(flow.loadError == nil)+    } }  /// Minimal async signal helper for tests. Awaiters suspend until `fire()`
prismTests/RemoteRefreshFlowTests.swift Modified +95 / -0
diff --git a/prismTests/RemoteRefreshFlowTests.swift b/prismTests/RemoteRefreshFlowTests.swiftindex 0e711c01..7e5f1ae9 100644--- a/prismTests/RemoteRefreshFlowTests.swift+++ b/prismTests/RemoteRefreshFlowTests.swift@@ -382,6 +382,82 @@ struct RemoteRefreshFlowTests {         #expect(recentFilesManager.recentFiles.first?.title == "New Title")     } +    /// Regression for T-1810: a refresh whose refetch redirects to a NEW+    /// final URL must update the session's stored remote base, so relative+    /// images/links resolve against where the content now actually lives —+    /// not against the URL the session was originally opened with.+    ///+    /// The refetch itself must go to the DISPLAY URL, the address the user+    /// opened — `remote` is the previous redirect's target, and fetching that+    /// again would never re-resolve a re-pointed alias (or would fail outright+    /// once a short-lived signed URL expired).+    @Test("Refresh re-fetches the display URL and updates the remote base when the redirect target changes")+    func refreshUpdatesRemoteBaseOnRedirectChange() async throws {+        let displayURL = URL(string: "https://a.example.com/latest.md")!+        // Where the alias pointed when the document was opened.+        let originalFetchURL = URL(string: "https://cdn.example.com/docs/v1/readme.md")!+        // Where it points now.+        let newFetchURL = URL(string: "https://cdn.example.com/docs/v2/readme.md")!+        let recentFilesManager = makeRecentFilesManager(suiteName: "RemoteRefreshFlowTests.redirectChange")++        let requestedURLs = RequestedURLs()+        let loader: RemoteRefreshFlow.Loader = { url in+            requestedURLs.record(url)+            return URLDocumentLoader.LoadResult(+                content: "# Moved\n\n![x](img.png)",+                fetchURL: newFetchURL,+                displayURL: displayURL+            )+        }++        let flow = RemoteRefreshFlow(loader: loader)+        let session = DocumentSession(remoteURL: originalFetchURL, displayURL: displayURL, content: "# Original")++        flow.refresh(session: session, recentFilesManager: recentFilesManager)+        await flow.task?.value++        #expect(+            requestedURLs.urls == [displayURL],+            "Refresh must re-fetch the address the user opened, not the previous redirect's target"+        )+        guard case .url(let remote, let display) = session.source else {+            Issue.record("Expected a .url source after refresh")+            return+        }+        #expect(remote == newFetchURL, "The remote base must move to the redirect's final URL")+        #expect(display == displayURL, "The display URL must stay the user's original URL, never the redirect target")+        #expect(+            session.source.imageBaseURL == URL(string: "https://cdn.example.com/docs/v2/")!,+            "Relative images must resolve against the new final URL after the refresh"+        )+    }++    /// A refresh that keeps resolving to the same final URL must not disturb+    /// the stored remote base (also verifies `updateRemoteBase` is a no-op+    /// rather than churning `source`, which is `@Observable`-tracked).+    @Test("Refresh leaves the remote base unchanged when the redirect target is stable")+    func refreshLeavesRemoteBaseUnchangedWhenStable() async throws {+        let displayURL = URL(string: "https://example.com/doc.md")!+        let recentFilesManager = makeRecentFilesManager(suiteName: "RemoteRefreshFlowTests.redirectStable")++        let loader: RemoteRefreshFlow.Loader = { _ in+            URLDocumentLoader.LoadResult(content: "# Same", fetchURL: displayURL, displayURL: displayURL)+        }++        let flow = RemoteRefreshFlow(loader: loader)+        let session = DocumentSession(remoteURL: displayURL, displayURL: displayURL, content: "# Original")++        flow.refresh(session: session, recentFilesManager: recentFilesManager)+        await flow.task?.value++        guard case .url(let remote, let display) = session.source else {+            Issue.record("Expected a .url source after refresh")+            return+        }+        #expect(remote == displayURL)+        #expect(display == displayURL)+    }+     /// A failed refresh records the error and clears `isRefreshing`.     @Test("Failed refresh records the error")     func failedRefreshRecordsError() async throws {@@ -585,3 +661,22 @@ private final class AsyncSignal: @unchecked Sendable {         for cont in pending { cont.resume() }     } }++/// Records the URLs a test loader was asked for, from whichever context the+/// flow's task calls it on.+private final class RequestedURLs: @unchecked Sendable {+    private let lock = NSLock()+    private var recorded: [URL] = []++    var urls: [URL] {+        lock.lock()+        defer { lock.unlock() }+        return recorded+    }++    func record(_ url: URL) {+        lock.lock()+        defer { lock.unlock() }+        recorded.append(url)+    }+}
prismTests/URLDocumentLoaderTests.swift Modified +61 / -0
diff --git a/prismTests/URLDocumentLoaderTests.swift b/prismTests/URLDocumentLoaderTests.swiftindex 60f6f202..20fafddf 100644--- a/prismTests/URLDocumentLoaderTests.swift+++ b/prismTests/URLDocumentLoaderTests.swift@@ -1020,6 +1020,67 @@ struct URLDocumentLoaderTests {         }     } +    // MARK: - Redirect Final URL (T-1810)++    /// Regression for T-1810: after a same-host redirect to a different path,+    /// `fetchURL` must be the FINAL response URL, not the originally+    /// requested one — otherwise relative images/links in the document+    /// resolve against a path the content did not actually come from.+    @Test("Same-host redirect: fetchURL is the final response URL")+    func sameHostRedirectUsesFinalURL() async throws {+        let url = URL(string: "https://example.com/latest.md")!+        let redirectTarget = URL(string: "https://example.com/docs/v2/readme.md")!+        let content = "# Redirected\n\n![x](img.png)"+        let data = Data(content.utf8)++        mockScope.handler = { request in+            if request.url == url {+                let response = self.mockResponse(url: url, statusCode: 302)+                return .redirect(response, URLRequest(url: redirectTarget))+            }+            #expect(request.url == redirectTarget)+            return .response(self.mockResponse(url: redirectTarget), data)+        }++        let result = try await URLDocumentLoader.load(+            from: url,+            sessionConfiguration: mockSessionConfig()+        )++        #expect(result.content == content)+        #expect(result.fetchURL == redirectTarget)+        #expect(result.displayURL == url, "displayURL must stay the user's original URL, never the redirect target")+    }++    /// Same as above but across hosts (e.g. a CDN-hosted redirect target),+    /// which is the scenario in the ticket's reproduction steps.+    @Test("Cross-host redirect: fetchURL is the final response URL")+    func crossHostRedirectUsesFinalURL() async throws {+        let url = URL(string: "https://a.example.com/latest.md")!+        let redirectTarget = URL(string: "https://cdn.example.com/docs/v2/readme.md")!+        let content = "# Redirected\n\n![x](img.png)\n\n[next](next.md)"+        let data = Data(content.utf8)++        mockScope.handler = { request in+            if request.url == url {+                let response = self.mockResponse(url: url, statusCode: 302)+                return .redirect(response, URLRequest(url: redirectTarget))+            }+            return .response(self.mockResponse(url: redirectTarget), data)+        }++        let result = try await URLDocumentLoader.load(+            from: url,+            sessionConfiguration: mockSessionConfig()+        )++        #expect(result.fetchURL == redirectTarget)+        #expect(result.displayURL == url)++        // The base a caller would resolve relative resources against.+        #expect(result.fetchURL.deletingLastPathComponent() == URL(string: "https://cdn.example.com/docs/v2/")!)+    }+     // MARK: - Content Type Rejection      @Test("JSON content type rejected for non-.md URL")
prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift Modified +80 / -0
diff --git a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swiftindex 23bbb327..f741a9de 100644--- a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift+++ b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift@@ -80,6 +80,12 @@ struct WebStateSynchronizerAssemblyTests {             url: URL(fileURLWithPath: "/tmp/t1719-assembly-fixture.md"),             content: content         )+        return await makeAssembly(session: session)+    }++    /// Mounts the production assembly over a caller-built session (parsing it+    /// first), for tests whose source is not a file.+    private func makeAssembly(session: DocumentSession) async -> Assembly {         await session.parseContent()         let coordinator = DocumentLayoutCoordinator()         let settings = AppSettings()@@ -372,4 +378,78 @@ struct WebStateSynchronizerAssemblyTests {         }         #expect(pushed, "section collapse must be pushed without the SwiftUI view's .onChange being mounted")     }++    // MARK: - Image-source changes riding a re-parse (T-1810 review)++    private static let originalRemote = URL(string: "https://a.example.com/latest.md")!+    private static let movedRemote = URL(string: "https://cdn.example.com/docs/v2/readme.md")!+    private static let movedBase = URL(string: "https://cdn.example.com/docs/v2/")!++    /// A URL refresh whose redirect target moved re-parses AND re-bases in one+    /// turn. The re-parse re-keys the surface's load task, and that revision load+    /// fetches every image against the new context — so the synchronizer must+    /// not issue a same-revision reload of its own for the base change, or the+    /// refresh costs two page loads (the second discarding the first's scroll+    /// restore).+    @Test("A remote base change landing with a re-parse defers the image reload to the revision load")+    func remoteBaseChangeWithReparseDefersToRevisionLoad() async throws {+        let session = DocumentSession(+            remoteURL: Self.originalRemote,+            displayURL: Self.originalRemote,+            content: "# One\n\n![x](img.png)"+        )+        let assembly = await makeAssembly(session: session)+        let revisionBefore = session.parseRevision+        #expect(+            assembly.controller.parseRevision == revisionBefore,+            "precondition: the page is at the session's revision, as after the surface's initial load"+        )++        await session.reloadContent(markdownString: "# Two\n\n![x](img.png)", remoteBase: Self.movedRemote)++        #expect(+            await waitUntil { assembly.controller.imageSource.value.imageBaseURL == Self.movedBase },+            "the page's image context must move with the base"+        )+        #expect(session.parseRevision == revisionBefore + 1)+        #expect(+            assembly.controller.parseRevision == revisionBefore,+            """+            the synchronizer issued its own reload for the base change; the revision load the \+            surface owes (Req 2.5) fetches against the new context already, so this is a \+            second page load for one refresh+            """+        )++        // The owed load arrives, exactly as the surface's `.task(id:)` issues it.+        await WebDocumentControllerFactory.loadDocument(+            controller: assembly.controller, session: session, settings: assembly.settings+        )+        #expect(assembly.controller.parseRevision == session.parseRevision)+    }++    /// The counterpart, guarding the comparison's direction: a base change with+    /// the page CURRENT has nothing else coming to re-request the images, so it+    /// still reloads the same revision (the T-1784 path).+    @Test("A remote base change with the page current still reloads the same revision")+    func remoteBaseChangeWithPageCurrentReloads() async throws {+        let session = DocumentSession(+            remoteURL: Self.originalRemote,+            displayURL: Self.originalRemote,+            content: "# One\n\n![x](img.png)\n\nTrailing prose to hold a reading position."+        )+        let assembly = await makeAssembly(session: session)+        let readingPosition = try #require(domID(at: session.parsedBlocks.count - 1, in: session))+        session.scrollPositionID = readingPosition+        let revision = session.parseRevision++        session.updateRemoteBase(to: Self.movedRemote)++        #expect(+            await waitUntil { assembly.controller.latestSnapshot.scrollTargetBlockID == readingPosition },+            "no same-revision reload followed the base change, so already-failed images never retry"+        )+        #expect(assembly.controller.imageSource.value.imageBaseURL == Self.movedBase)+        #expect(session.parseRevision == revision, "a base change alone must not re-parse")+    } }
specs/bugfixes/redirect-final-url-base/report.md Added +108 / -0
diff --git a/specs/bugfixes/redirect-final-url-base/report.md b/specs/bugfixes/redirect-final-url-base/report.mdnew file mode 100644index 00000000..670ab32c--- /dev/null+++ b/specs/bugfixes/redirect-final-url-base/report.md@@ -0,0 +1,108 @@+# Bugfix Report: Redirected Remote Documents Resolve Relative Resources Against The Original URL++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++When Prism opens a remote markdown document whose URL redirects (HTTP 301/302/etc.), the app kept the pre-redirect request URL as the document's "fetch" base rather than the URL that actually served the content. Any relative image or markdown link in the document was then resolved against the wrong host/path.++**Reproduction steps:**+1. Open `https://a.example.com/latest.md`, which redirects (302) to `https://cdn.example.com/docs/v2/readme.md`.+2. The redirected document's body contains a relative resource, e.g. `![x](img.png)` or `[next](next.md)`.+3. Observe: Prism resolves `img.png`/`next.md` under `https://a.example.com/`, not `https://cdn.example.com/docs/v2/` — the image fails to load and the link opens the wrong URL.++**Impact:** Relative images fail to load and relative markdown links resolve incorrectly for any redirecting endpoint — version aliases, CDN-hosted documents, URL shorteners, etc. A URL refresh compounded the problem: because nothing updated the stored remote base, a document whose redirect target changed between opens (e.g. a moved CDN alias) would keep resolving against a base that no longer matched where the content lived.++## Investigation Summary++- **Symptoms examined:** Traced `LoadResult` from `URLDocumentLoader.load` through to where the image/link base URL is derived.+- **Code inspected:**+  - `prism/Services/URLDocumentLoader.swift` — recently rewritten for T-2260 (`ChunkedBodyLoader`, a `URLSessionDataDelegate`-based streaming loader with its own redirect validation), so the current implementation was read directly rather than assumed from the ticket's line numbers (which referenced an older revision).+  - `prism/Models/DocumentSource.swift` — `imageBaseURL` derives the relative-resource base from the `.url(remote:display:)` case's `remote` associated value.+  - `prism/ViewModels/DocumentFlowCoordinator.swift` — `openRemoteSession` stores the loader's `fetchURL` as that `remote` value.+  - `prism/ViewModels/RemoteRefreshFlow.swift` — drives the URL refresh (Refresh button); applies the refetched content but never touched the session's stored remote base.+- **Hypotheses tested:** Confirmed `ChunkedBodyLoader.urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)` already validates and follows the redirect correctly (Req 8.1/8.2 unaffected) — the defect is purely in which URL gets reported back as `fetchURL`, not in whether the redirect itself is followed.++## Discovered Root Cause++`URLDocumentLoader.load` receives the final `HTTPURLResponse` (used for status-code and content-type validation) but built its returned `LoadResult.fetchURL` from `transformed.fetchURL` — the GitHub-transformed *request* URL, computed before the network call — instead of `httpResponse.url`, the URL the response actually came from after following redirects.++**Defect type:** Logic error — using the wrong URL variable when constructing the return value; the correct (post-redirect) URL was available but discarded.++**Why it occurred:** `transformed.fetchURL` is a natural-looking variable name to reach for at the return statement, since it was also used to build the request. The final response's own `url` property was only consulted for status/content-type checks, not carried forward into the result.++**Contributing factors:** No existing test exercised a redirect that both changes the URL AND asserts what `fetchURL` becomes afterward — the only redirect tests covered redirect *rejection* (embedded credentials, non-HTTP schemes), not the success path's returned URL.++## Resolution for the Issue++**Changes made:**+- `prism/Services/URLDocumentLoader.swift` — `LoadResult.fetchURL` is now built from `httpResponse.url ?? transformed.fetchURL` (falling back only if a response is ever constructed with no URL, which does not happen for a real network response). Doc comments on `LoadResult.fetchURL`/`displayURL` clarify the fetch URL is the final post-redirect URL and the display URL never changes.+- `prism/Models/DocumentSession.swift` — added `updateRemoteBase(to:)`, which updates the `.url` source's `remote` component in place (preserving `displayURL`) and no-ops for any other source or an unchanged URL.+- `prism/ViewModels/RemoteRefreshFlow.swift` — `refresh(session:recentFilesManager:)` now hands `result.fetchURL` to `session.reloadContent(markdownString:remoteBase:)`, so a refresh whose redirect target has moved updates the stored remote base rather than leaving it stale.+- `prism/Models/DocumentSession.swift` — `reloadContent(markdownString:remoteBase:)` applies that base in the same synchronous stretch as the parse result (and not at all for a parse superseded under T-718), so the new blocks, the revision bump and the new base reach `WebDocumentStateSynchronizer` in one coalesced pass. Applying the base *before* the parse, as first suggested in review, would have made the pass run during the parse's suspension and reload the page at the old revision.+- `prism/ViewModels/WebDocumentStateSynchronizer.swift` — the image-source domain no longer issues its same-revision reload (the T-1784 path) when the page on screen is behind the session's `parseRevision`: the surface's revision load (Req 2.5) fetches every image against the freshly written context already, so the extra reload only brought up a page that load was about to replace — two page loads for one refresh (review of PR #407). With the page current, a base change alone still reloads.++**Approach rationale:** The fix is minimal and localized to where the bug actually lives: return the URL the response really came from, and let an in-place refresh update that same field when it changes. `DocumentSource.imageBaseURL` and `DocumentFlowCoordinator.openRemoteSession` needed no changes — they already correctly use whatever `remote`/`fetchURL` value they're given; they were just being given the wrong one.++**Alternatives considered:**+- Resolving relative resources against the response URL at usage time (in `ImagePathResolver`/`LinkPathResolver`) instead of fixing the stored base — rejected because it would require plumbing the final URL through every read site instead of fixing it once at the source, and would leave the stored session identity URL wrong for anything else that reads `DocumentSource.url`/`remote`.++## Regression Test++**Test files:**+- `prismTests/URLDocumentLoaderTests.swift` — `sameHostRedirectUsesFinalURL`, `crossHostRedirectUsesFinalURL`+- `prismTests/RemoteRefreshFlowTests.swift` — `refreshUpdatesRemoteBaseOnRedirectChange`, `refreshLeavesRemoteBaseUnchangedWhenStable`+- `prismTests/DocumentSessionRemoteBaseTests.swift` (new file) — `updateRemoteBaseMovesRemoteKeepsDisplay`, `updateRemoteBaseNoOpForNonURLSource`, `updateRemoteBaseNoOpWhenUnchanged`, `reloadContentAppliesRemoteBaseWithParse`, `reloadContentWithoutRemoteBaseLeavesSource`, `supersededReloadAppliesNoRemoteBase`+- `prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift` — `remoteBaseChangeWithReparseDefersToRevisionLoad`, `remoteBaseChangeWithPageCurrentReloads`++**What it verifies:**+- A same-host and a cross-host HTTP redirect both result in `LoadResult.fetchURL` equal to the final response URL, while `displayURL` stays the originally requested URL.+- A URL refresh whose refetch redirects to a new final URL updates the session's stored remote base (and therefore `DocumentSource.imageBaseURL`) without touching `displayURL`; a refresh that keeps resolving to the same URL leaves the source untouched.+- `DocumentSession.updateRemoteBase(to:)` is a no-op for non-`.url` sources and for an unchanged remote URL.+- `reloadContent(markdownString:remoteBase:)` applies the base with the parse result, and a superseded reload applies neither.+- Over the production web assembly, a base change landing with a re-parse leaves the controller at the old revision (no synchronizer-issued reload; the owed revision load then brings it current), while a base change with the page current still reloads and offers the stored reading position.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -testPlan prism -only-test-configuration "en (base)" \+  -only-testing:prismTests/URLDocumentLoaderTests \+  -only-testing:prismTests/RemoteRefreshFlowTests \+  -only-testing:prismTests/DocumentSessionRemoteBaseTests \+  -only-testing:prismTests/WebStateSynchronizerAssemblyTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/URLDocumentLoader.swift` | `LoadResult.fetchURL` now uses `httpResponse.url` (final, post-redirect) instead of the pre-redirect `transformed.fetchURL` |+| `prism/Models/DocumentSession.swift` | Added `updateRemoteBase(to:)` to move the `.url` source's remote component in place while preserving `displayURL`; `reloadContent(markdownString:remoteBase:)` applies it in the same turn as the parse result |+| `prism/ViewModels/RemoteRefreshFlow.swift` | Hands the final URL to `reloadContent(markdownString:remoteBase:)` so a changed redirect target updates the stored base with the new content |+| `prism/ViewModels/WebDocumentStateSynchronizer.swift` | Skips the same-revision image-context reload when the page is behind the session's revision — the owed revision load re-fetches the images |+| `prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift` | Coalescing regression tests over the production assembly |+| `prismTests/URLDocumentLoaderTests.swift` | New redirect regression tests for `fetchURL` |+| `prismTests/RemoteRefreshFlowTests.swift` | New regression tests for remote-base update on refresh |+| `prismTests/DocumentSessionRemoteBaseTests.swift` | New file: unit tests for `updateRemoteBase(to:)` |++## Verification++**Automated:**+- [x] Regression tests pass (`URLDocumentLoaderTests`, `RemoteRefreshFlowTests`, `DocumentSessionRemoteBaseTests`, `RemoteContentCoordinatorTests` — 48/48 passed)+- [x] Related suites pass (`DocumentFlowCoordinatorURLTests`, `ImageIntegrationTests`, `DocumentSessionTests` — 147/147 passed)+- [x] `make lint` passes+- [x] `make build-macos` passes+- [ ] Full `make test-quick`/`make test` — not run; per project guidance this is unreliable under concurrent worktree contention on this machine, so verification relied on targeted `-only-testing:` runs confirmed via `Tools/check-test-results.sh` instead.++**Manual verification:** Not performed (no live redirecting test server); coverage relies on `MockURLProtocol`'s `.redirect` case, which drives the same `URLSessionDataDelegate` redirect callback (`willPerformHTTPRedirection`) that production traffic uses.++## Prevention++**Recommendations to avoid similar bugs:**+- When a network layer captures both a "requested" and a "final/response" URL, name and document them distinctly at the point both exist (as `LoadResult`'s doc comments now do), so a later return statement is less likely to reach for the wrong one.+- Any loader that reports a URL to callers should have a redirect test asserting what that reported URL *is*, not just that redirects are validated/rejected correctly.++## Related++- Transit ticket: T-1810
specs/open-from-url/decision_log.md Modified +1 / -0
diff --git a/specs/open-from-url/decision_log.md b/specs/open-from-url/decision_log.mdindex 790461a5..9bb7f6fe 100644--- a/specs/open-from-url/decision_log.md+++ b/specs/open-from-url/decision_log.md@@ -5,6 +5,7 @@ | ID | Date | Decision | Rationale | |----|------|----------|-----------| | 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) | Relative images/links must resolve against where the bytes actually came from, so `URLDocumentLoader.LoadResult.fetchURL` reports `httpResponse.url` and that is what becomes `remote`. Once `remote` is a redirect's target, refreshing from it would pin the document to that target forever — a re-pointed alias would never re-resolve, and a short-lived signed CDN URL would fail once expired — so `RemoteRefreshFlow` re-fetches `display` (the loader re-validates and re-applies the GitHub transform) and moves `remote` to the new final URL via `reloadContent(markdownString:remoteBase:)`, in the same turn as the parse so the page reloads once. Refines Decision 1: `display` is still identity; `remote` is now "served from" rather than "fetched from". |  ## Decision 1: Use Display URL for Identity, Fetch URL for Downloading 

Things to double-check

The full test suite has not passed on this machine — for either branch.

Two make test-quick runs: 15 failures, then 37, with near-disjoint sets. Run 2 is dominated by NSCache evictions (ImageCacheTests, SnapshotCacheTests — every assertion is a cache get returning nil) and WebKit .loadTimedOut in the live spike suites. The three non-timeout failures — DocumentLayoutCoordinatorReloadTests, RawSourceViewModelTests, MermaidRendererTests — all pass in isolation. Totals differed between runs (4339 vs 4533 executed), which is itself a host-instability signal.

I am confident none of it is this change: no failing suite touches URL loading, the document source, or the synchronizer's image-source domain. But the honest statement is “this branch has no clean full-suite run”, not “tests pass”, and the same is almost certainly true of main on this machine right now. Worth one clean run before merge on a quiet machine, or in CI.

This PR adds two more live-WebKit tests to an already-capped budget.

WebStateSynchronizerAssemblyTests gains two tests that each build a real WebPage via makeAssembly. The .liveWebKit 32-permit budget exists precisely for this and both tests are correctly annotated, so nothing is wrong — but the suite is now marginally heavier, and the failure mode when that budget is under-provisioned is the .loadTimedOut cascade seen in both runs above. Not a reason to change anything; a reason not to read those timeouts as unrelated noise forever.

Refresh now re-validates what it previously skipped.

The old path fetched remote specifically to bypass the GitHub transform ("so GitHubURLTransformer inside load() will pass it through unchanged"). Refreshing display re-runs the transform and the scheme/credential validation on every refresh. That is almost certainly desirable — it is the same validation the initial open ran, over the same URL — but it means a Refresh can now fail validation, where before it could only fail on the network. No test composes the two halves; see the coverage-gap finding.