PR #42 — HeadMetadataScanner.parseAttributes now runs every attribute value through HTMLEntityDecoder.decode, so a fetched <link rel="canonical" href="…?id=12&ch=4"> yields the URL the page means. One production line, two regression tests, one bugfix report.
parseAttributes decodes character references in every attribute value before storing it — href and rel alike.Preprocessing.js) is unaffected: getAttribute on a parsed DOM already returns decoded values.Entry.canonicalURLString values with a literal & are left as they are — repairing them is an identity migration, recorded as a quick decision in the bugfix report.HTMLEntityDecoder.swift, PageTitleFetcher.swift scanner doc, specs/immutable-capture-safety-net/design.md §171, T-2116 report Related bullet.rel="alternate canonical" test relies on Swift's isWhitespace treating U+00A0 as a separator; the HTML spec splits rel on ASCII whitespace only. Pre-existing tokeniser behaviour.Ready to push
The fix is the smallest correct one: one call site, reusing the decoder T-2116 already tested, placed where both href and rel are produced. make test-core passes with no compiler warnings. The review found only stale documentation (decoder and scanner doc comments, the safety-net design doc, the T-2116 report's forward pointer), all fixed in the working tree and uncommitted. One note for the author about the second regression test pinning non-spec whitespace tokenisation — pre-existing behaviour, not a blocker.
b8edc5a T-2119: decode HTML entities in HeadMetadataScanner attribute values working-tree Doc-only fixes applied in this review (not committed) When Asterism saves a link that was not shared from Safari, it downloads the top of the web page and looks for two things: the page's title and its canonical URL (the address the page says is its 'real' one). Web pages write special characters in a coded form — & means a plain &. The title was already being translated back to plain text; the canonical address was not, so an address like …?id=12&ch=4 was stored as …?id=12&ch=4, which is a different address.
Asterism uses the canonical address to recognise the same page when you share it again and to spot duplicates. A wrong address means those features silently miss.
&, ’). Think of it as shorthand the browser expands.name="value" pairs inside an HTML tag; href is the one holding the address.HeadMetadataScanner.parseAttributes (PageTitleFetcher.swift) stored quoted and unquoted attribute values as raw substrings. It now stores HTMLEntityDecoder.decode(value). Two tests were added: one asserting a canonical href with & scans to a decoded URL, one asserting rel tokenisation still works after decoding. A bugfix report was written under specs/bugfixes/canonical-href-html-entities/.
Decoding happens at the point values enter the attribute dictionary rather than per-attribute at the link call site. That is deliberate: rel is whitespace-tokenised from the same dictionary, so a single decode covers both consumers and any future one. The decoder already early-returns when the value has no &, and the scanner is bounded (64 KiB, stops at </head>), so the per-attribute call is not a hot-path concern.
href at the call site was rejected — misses rel.resolveFirstValidCanonical was rejected — leaves ScannedHeadMetadata internally inconsistent and cannot fix tokenisation, which happens earlier.The change is spec-aligned: the HTML tokenizer resolves character references in attribute values (the 'attribute value (double-quoted)' state consumes references with the in-attribute flag), so a hand-rolled scanner that skipped this produced values no browser would. HTMLEntityDecoder omits one in-attribute nuance — in attribute values a named reference without a trailing ; followed by = or an alphanumeric is not decoded (the legacy ©=1 rule). The decoder requires a ; for every reference, which is strictly more conservative than the spec on that path and never produces the wrong answer for a well-formed page; for malformed & without a semicolon it leaves the text alone, same as before.
The decoder's documented contract ('titles are the only thing this exists for') was widened by this change without the doc being updated; fixed in review. ScannedHeadMetadata is now consistently decoded on both surfaces, which is what downstream identity code (ShareTransport.resolveFirstValidCanonical → CapturePreparation.canonicalURL) assumes.
rel tokenisation uses Character.isWhitespace (Unicode White_Space) whereas the spec splits on ASCII whitespace only. The new test relTokenisationSurvivesEntityDecoding pins the U+00A0-splits behaviour, which a browser would not exhibit. Harmless in practice, but the test now documents a divergence as if it were the requirement; a numeric-entity example (rel="canonical") would prove 'decode before tokenise' without leaning on the divergence.content, type, …). Cost is bounded by the 64 KiB cap; no correctness impact today.& remain; a re-share of such a page after this fix will produce a differently keyed canonical URL and may not match the old Entry. Accepted in the report's quick decision.Fully implemented: attribute decoding, both regression tests, bugfix report with alternatives and the stored-rows decision. Nothing partial or missing for the ticket's scope. Out of scope and unchanged: ASCII-only rel tokenisation, stored-row repair.
PageTitleFetcher.swift
Why it matters. The one production change. Fixes canonical-URL identity for every fetched page whose canonical href carries an entity.
What to look at. PageTitleFetcher.swift:221-226 (parseAttributes)
HTMLEntityDecodingTests.swift
Why it matters. Pins the reported symptom: & inside a canonical href must reach ScannedHeadMetadata as &.
What to look at. HTMLEntityDecodingTests.swift:126-143 (scannerDecodesCanonicalHrefEntity)
PageTitleFetcherTests.swift
Why it matters. Proves decoding runs before tokenisation, but does so via U+00A0, which the HTML spec does not treat as a rel separator.
What to look at. PageTitleFetcherTests.swift:286-303 (relTokenisationSurvivesEntityDecoding)
report.md
Why it matters. Records the root cause, the Safari-path check, the rejected alternatives and the decision not to repair stored rows.
What to look at. report.md (whole file)
One decode in parseAttributes covers rel tokenisation and href. Stated in the inline comment and the report.
Keeps ScannedHeadMetadata consistent and fixes tokenisation, which happens before candidates leave the scanner. Stated in the report's Alternatives.
Identity migration with collision handling for a small population; recorded as a quick decision in the report with a sketch of the narrow repair if ever needed.
Preprocessing.js reads getAttribute("href") from the parsed DOM, which is already decoded. Verified by reading the source; stated in the report.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | HTMLEntityDecoder.swift doc comment | Header said 'Titles are the only thing this exists for' — no longer true once attribute values go through it. | Reworded to cover title text (T-2116) and head attribute values (T-2119). |
| minor | HeadMetadataScanner doc comment | Struct doc in the file this commit touches still said only title text is decoded. | Now says title text and attribute values (href, rel). |
| minor | specs/immutable-capture-safety-net/design.md §171 | The design doc T-2116 already updated once still described decoding as title-only. | Sentence widened and the T-2116/T-2119 provenance note updated. |
| minor | specs/bugfixes/html-entities-in-titles/report.md Related | Forward pointer described the attribute gap as open and 'deserves its own ticket'. | Now says it was closed by T-2119 and links the new report. |
| minor | PageTitleFetcherTests.swift relTokenisationSurvivesEntityDecoding | Test pins U+00A0 as a rel separator. HTML splits rel on ASCII whitespace only; a browser would read 'alternate canonical' as one token. The divergence is pre-existing (isWhitespace tokeniser), and the test correctly documents current behaviour. | Left as is — test files are not modified in review unless wrong, and the report already names this. Author may prefer a numeric-entity example (rel="canonical") that proves ordering without relying on NBSP. |
| info | Code reuse / efficiency | No other attribute parser or decoder exists in Swift sources; decode early-returns without '&' and the scan is bounded at 64 KiB / </head>. | Nothing to change. |
Click to expand.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift b/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swiftindex 25cb198..8bac3e3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift@@ -218,7 +218,12 @@ public struct HeadMetadataScanner: Sendable { } value = String(text[valueStart..<index]) }- if !attrName.isEmpty { attrs[attrName] = value }+ // Attribute values carry character references the same as text+ // content does (`&` inside an `href` is required by the HTML+ // spec whenever the URL itself contains `&`) — decode them the+ // same way title text is decoded (T-2116), not just for `href`,+ // since `rel` is tokenised from this same dictionary (T-2119).+ if !attrName.isEmpty { attrs[attrName] = HTMLEntityDecoder.decode(value) } } else { // Boolean attribute (no value) if !attrName.isEmpty { attrs[attrName] = "" }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/HTMLEntityDecodingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/HTMLEntityDecodingTests.swiftindex 071aa5e..0ee5919 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/HTMLEntityDecodingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/HTMLEntityDecodingTests.swift@@ -123,6 +123,25 @@ struct HTMLEntityDecodingTests { #expect(result.title == "¬arealentity; text") } + /// Regression coverage for T-2119: a canonical `href` with two or more query+ /// parameters is spec-required to entity-encode the `&` joining them+ /// (`<link rel="canonical" href="https://example.com/read?id=12&ch=4">`).+ /// Before the fix, `parseAttributes` read attribute values as raw substrings,+ /// so the scanner's candidate carried the literal `&` rather than `&` —+ /// a different URL from the one the page means.+ @Test("Scanner decodes an entity-bearing canonical href")+ func scannerDecodesCanonicalHrefEntity() {+ let scanner = HeadMetadataScanner()+ let html = """+ <html><head>+ <link rel="canonical" href="https://example.com/read?id=12&ch=4">+ <title>Page</title>+ </head></html>+ """+ let result = scanner.scan(Data(html.utf8))+ #expect(result.canonicalCandidates == ["https://example.com/read?id=12&ch=4"])+ }+ // MARK: - Share-sheet titles (SharePayloadExtractor) @Test("Host-supplied title has its entities decoded")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swiftindex 8872f9d..80db8ff 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift@@ -283,6 +283,25 @@ struct PageTitleFetcherTests { #expect(result.canonicalCandidates == ["https://example.com/multi"]) } + /// Regression coverage for T-2119: `rel` is tokenised from the same+ /// attribute dictionary `href` is read from, so decoding must run before+ /// tokenising. An entity-encoded separator (` ` decodes to U+00A0,+ /// which Swift's `Character.isWhitespace` recognises) must still split the+ /// token list correctly, not survive as literal text glued to its+ /// neighbours.+ @Test("Canonical still detected when rel tokens are separated by a decoded entity")+ func relTokenisationSurvivesEntityDecoding() async throws {+ let scanner = HeadMetadataScanner()+ let html = """+ <html><head>+ <link rel="alternate canonical" href="https://example.com/entity-separated">+ <title>T</title>+ </head></html>+ """+ let result = scanner.scan(Data(html.utf8))+ #expect(result.canonicalCandidates == ["https://example.com/entity-separated"])+ }+ // MARK: - First nonblank title returned @Test("First nonblank title element is returned")
diff --git a/specs/bugfixes/canonical-href-html-entities/report.md b/specs/bugfixes/canonical-href-html-entities/report.mdnew file mode 100644index 0000000..3f13c47--- /dev/null+++ b/specs/bugfixes/canonical-href-html-entities/report.md@@ -0,0 +1,199 @@+# Bugfix Report: Fetched Canonical Hrefs Keep Their HTML Entities++**Date:** 2026-08-29+**Status:** Fixed++## Description of the Issue++`HeadMetadataScanner.parseAttributes` (`Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift`)+read every HTML attribute value as a raw substring and never decoded character+references in it. Title text already went through `HTMLEntityDecoder`+(T-2116); attribute values did not.++A page carrying `<link rel="canonical" href="https://example.com/read?id=12&ch=4">`+therefore yielded the candidate `https://example.com/read?id=12&ch=4` — a+different string, and a different URL, from the one the page means. That+string flowed through `resolveFirstValidCanonical` into+`CapturePreparation.canonicalURL` and was stored verbatim as+`Entry.canonicalURLString`. Entity-encoding `&` inside an `href` is required+by the HTML spec whenever the URL itself contains an unencoded `&`, so any+canonical URL with two or more query parameters was a candidate for this bug.++**Reproduction steps:**+1. Share a non-Safari link (or otherwise exercise the network-fetch path in+ `BoundedPageTitleFetcher`) for a page whose `<head>` contains+ `<link rel="canonical" href="https://example.com/read?id=12&ch=4">`.+2. Observe `ScannedHeadMetadata.canonicalCandidates` containing the literal+ substring `&` instead of a decoded `&`.+3. The candidate survives `resolveFirstValidCanonical` (it is still a+ syntactically valid absolute HTTP(S) URL, just the wrong one) and is stored+ as `Entry.canonicalURLString`.++**Impact:** Any fetched (non-Safari share) canonical URL with two or more+query parameters was stored with a literal `&` rather than `&`, corrupting+URL identity for that Entry (re-share matching, duplicate detection, and+display all key off the stored string). Titles containing entities were+already fixed by T-2116; this ticket closes the equivalent gap for attribute+values, which T-2116 deliberately left out of scope.++## Investigation Summary++- **Symptoms examined:** Traced `canonicalCandidates` from+ `HeadMetadataScanner.parse` through `parseAttributes`, then followed the+ `href` value from `ScannedHeadMetadata` through+ `BoundedPageTitleFetcher.fetch`, `ShareTransport.resolveFirstValidCanonical`,+ and `CapturePreparation.canonicalURL` into where it is ultimately persisted+ as `Entry.canonicalURLString`.+- **Code inspected:** `PageTitleFetcher.swift` (scanner and attribute parser),+ `HTMLEntityDecoder.swift` (the existing decoder, unchanged), `ShareTransport.swift`+ (`resolveFirstValidCanonical`), and `Preprocessing.js` (the Safari extraction+ path) to confirm the two acquisition paths behave differently.+- **Hypotheses tested:**+ - Whether the Safari path (`Preprocessing.js`) is also affected — ruled out:+ it calls `getAttribute("href")` on a DOM the browser has already parsed,+ and per the DOM/HTML spec `getAttribute` returns the attribute's value+ with character references already resolved. Confirmed by reading the+ source directly rather than assuming (per the ticket's instruction).+ - Whether `resolveFirstValidCanonical` silently drops candidates that fail+ to parse as a valid absolute HTTP(S) URL — confirmed yes (see "Decisions"+ below); this is pre-existing behaviour, not part of this bug, but+ relevant to its impact assessment.++## Discovered Root Cause++`HeadMetadataScanner.parseAttributes` builds its `[String: String]` attribute+dictionary directly from the raw text between quotes (or up to the next+whitespace/`>` for unquoted values), with no decoding step. Every other place+in the scanner that produces reader-facing text (`<title>` content) routes+through `decodeEntities` → `HTMLEntityDecoder.decode`; attribute parsing was+simply never wired to it.++**Defect type:** Missing processing step (an existing, tested decoder was not+applied to a second textual surface that needed it).++**Why it occurred:** T-2116 added `HTMLEntityDecoder` and wired it into title+extraction only, deliberately leaving attribute values out of scope to keep+that fix narrowly targeted. This ticket was filed at the same time to track+the remaining gap and given its own scope.++**Contributing factors:** `rel` and `href` are read from the same attribute+dictionary, so the gap affected both — `rel` tokenisation on an entity-encoded+separator (e.g. ` `) would also silently fail to recognise `canonical`,+though the primary and originally-reported symptom is the `href` value.++## Resolution for the Issue++**Changes made:**+- `Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift` —+ `parseAttributes` now calls `HTMLEntityDecoder.decode(value)` before storing+ each attribute value in the returned dictionary, applied to every attribute+ (not just `href`), since `rel` tokenisation reads from the same dictionary.++**Approach rationale:** This is exactly the fix suggested by the ticket and+the smallest change that closes the gap: one call site, reusing the decoder+T-2116 already built and tested, applied at the point where every attribute+value is produced rather than duplicated per-attribute at each call site.++**Alternatives considered:**+- **Decode only `href` at the `link`-tag call site** — rejected because `rel`+ is tokenised from the same raw dictionary, and an entity-encoded token+ separator would then silently fail to tokenise correctly; decoding once in+ `parseAttributes` covers both without special-casing.+- **Decode at `resolveFirstValidCanonical`/coordinator level instead of the+ scanner** — rejected because it would leave the scanner's own output+ (`ScannedHeadMetadata`) internally inconsistent (titles decoded, attributes+ not), and would miss the `rel`-tokenisation gap entirely since tokenisation+ happens inside the scanner before candidates ever reach the coordinator.++## Regression Test++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/HTMLEntityDecodingTests.swift`+**Test name:** `scannerDecodesCanonicalHrefEntity` ("Scanner decodes an+entity-bearing canonical href")++**What it verifies:** A `<link rel="canonical" href="https://example.com/read?id=12&ch=4">`+scans to the candidate `https://example.com/read?id=12&ch=4` (decoded), not+the literal `&` string.++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift`+**Test name:** `relTokenisationSurvivesEntityDecoding` ("Canonical still+detected when rel tokens are separated by a decoded entity")++**What it verifies:** `rel="alternate canonical"` (an entity-encoded+whitespace separator, which decodes to U+00A0 and is recognised by Swift's+`Character.isWhitespace`) still tokenises correctly and the link is still+recognised as canonical — proving decoding runs before tokenisation, not+after.++Both tests were confirmed to fail before the fix (red) and pass after it+(green).++**Run command:** `swift test --package-path Packages/AsterismCore --filter "HTMLEntityDecodingTests|PageTitleFetcherTests" --no-parallel`++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift` | `parseAttributes` decodes HTML entities in every attribute value via `HTMLEntityDecoder.decode` |+| `Packages/AsterismCore/Tests/AsterismCoreTests/HTMLEntityDecodingTests.swift` | New regression test for an entity-bearing canonical `href` |+| `Packages/AsterismCore/Tests/AsterismCoreTests/PageTitleFetcherTests.swift` | New regression test for `rel` tokenisation after entity decoding |++## Verification++**Automated:**+- [x] Regression tests pass (`scannerDecodesCanonicalHrefEntity`, `relTokenisationSurvivesEntityDecoding`)+- [x] Full `make test-core` passes (AsterismCore package + AsterismIntelligence)+- [x] No new compiler warnings (this project has no linter/formatter configured; a clean `make test-core` is the pre-commit bar)++**Manual verification:** Confirmed by direct source reading (not assumption)+that the Safari path (`Asterism/AsterismShareExtension/Preprocessing.js`) is+unaffected: it reads `links[index].getAttribute("href")` on the browser's own+parsed DOM, which per the DOM spec returns character references already+resolved. No change was needed or made there.++## Decisions++**Quick Decision — leave already-stored `canonicalURLString` values with a+literal `&` unrepaired.** Same shape of question as T-2116's stored-rows+limitation (`specs/bugfixes/html-entities-in-titles/report.md`): canonical+URLs participate in Entry identity, so rewriting stored values is a data+migration, not a string fix, and would need its own duplicate-collision+handling if a repaired URL now collides with an existing Entry's identity.+The fetch path (non-Safari shares) is a minority of captures, and canonical+URLs with two or more query parameters are a further subset of those, so the+affected population is expected to be small. This fix applies from the next+fetch onwards; no migration was written. If this needs revisiting later, a+targeted one-off repair script (find `Entry.canonicalURLString` containing a+literal `&`, decode and re-check identity collisions) would be the narrow+way to do it, following the same conservative-migration philosophy as+`specs/url-identity-re-share/decision_log.md` Decision 18.++**Note — invalid canonical candidates are already silently dropped.**+`ShareTransport.resolveFirstValidCanonical` (`ShareTransport.swift:277`)+already skips any candidate that fails to resolve to an absolute HTTP(S) URL+with a nonblank host, and returns `nil` with no error or log if none of a+page's candidates qualify. This is pre-existing behaviour, unrelated to this+fix — it does not mean any instance of this specific bug was being silently+dropped instead of stored wrong (an entity-encoded `&` inside an otherwise+well-formed `https://...` URL still parses as a valid, syntactically+well-formed URL, just the wrong one) — but it is worth recording that the+general "candidate is malformed" case fails silently rather than surfacing+anywhere.++## Prevention++**Recommendations to avoid similar bugs:**+- When adding a text-decoding step (entity decoding, normalization, etc.),+ audit every place raw HTML/text data crosses into a typed value, not just+ the one call site that prompted the fix — `HeadMetadataScanner` had two+ textual surfaces (`<title>` content and attribute values) and only one was+ wired up initially.+- `rel` and `href` sharing one attribute dictionary means a fix to one+ attribute's handling generically fixes the other; prefer decoding at the+ point values enter the dictionary over per-attribute special-casing.++## Related++- T-2116 (`specs/bugfixes/html-entities-in-titles/report.md`) — introduced+ `HTMLEntityDecoder` and wired it into title extraction; this ticket closes+ the attribute-value gap that T-2116 deliberately left out of scope.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/HTMLEntityDecoder.swift b/Packages/AsterismCore/Sources/AsterismCore/HTMLEntityDecoder.swiftindex ea855f5..c1f8793 100644--- a/Packages/AsterismCore/Sources/AsterismCore/HTMLEntityDecoder.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/HTMLEntityDecoder.swift@@ -4,9 +4,10 @@ import Foundation /// /// This is an entity decoder and nothing else: it never interprets tags, /// comments, scripts, or any other markup, so a `<script>` in the input stays-/// the literal characters `<script>`. Titles are the only thing this exists-/// for, and a title is text — the risk a full HTML parser would carry has no-/// place here (T-2116).+/// the literal characters `<script>`. It exists for text a page hands the+/// reader — `<title>` content (T-2116) and `<head>` attribute values such as a+/// canonical `href` (T-2119) — and text is all it ever sees, so the risk a+/// full HTML parser would carry has no place here. /// /// Recognised references are the HTML 4 named set plus `apos`, and numeric /// references in decimal (`’`) or hexadecimal (`’`). Anythingdiff --git a/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift b/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swiftindex 8bac3e3..d4a6393 100644--- a/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/PageTitleFetcher.swift@@ -20,8 +20,8 @@ public struct ScannedHeadMetadata: Sendable, Equatable { /// Streaming bounded scanner that extracts `<title>` and canonical `<link>` candidates /// from the first ≤65,536 bytes of an HTML `<head>`. Stops at `</head>` or the byte limit. /// Handles quoted/unquoted attributes, ASCII-case-insensitive tags, and whitespace-tokenized `rel`.-/// Title text is decoded through `HTMLEntityDecoder` — numeric references and the-/// HTML 4 named set; unknown named entities remain literal.+/// Title text and attribute values (`href`, `rel`) are decoded through `HTMLEntityDecoder` —+/// numeric references and the HTML 4 named set; unknown named entities remain literal. public struct HeadMetadataScanner: Sendable { public init() {} diff --git a/specs/bugfixes/html-entities-in-titles/report.md b/specs/bugfixes/html-entities-in-titles/report.mdindex 48806a4..230879f 100644--- a/specs/bugfixes/html-entities-in-titles/report.md+++ b/specs/bugfixes/html-entities-in-titles/report.md@@ -193,9 +193,10 @@ already handles. ## Related -- Adjacent gap, **not** fixed here: `HeadMetadataScanner.parseAttributes` does+- Adjacent gap, **not** fixed here: `HeadMetadataScanner.parseAttributes` did not decode entities in attribute values, so a `<link rel="canonical">` whose- href contains `&` yields a URL with the literal entity in it. That affects- URL identity rather than titles and deserves its own ticket.+ href contains `&` yielded a URL with the literal entity in it. That affects+ URL identity rather than titles and got its own ticket — closed by T-2119+ (`specs/bugfixes/canonical-href-html-entities/report.md`). - `specs/immutable-capture-safety-net/design.md` §171 — title acquisition design - `specs/duplicate-reconciliation/` — where the identity-shift consequence landsdiff --git a/specs/immutable-capture-safety-net/design.md b/specs/immutable-capture-safety-net/design.mdindex 7ab9f1b..0f413d1 100644--- a/specs/immutable-capture-safety-net/design.md+++ b/specs/immutable-capture-safety-net/design.md@@ -168,7 +168,7 @@ The preprocessing script returns `document.title`, `location.href`, and every `l `PageTitleFetcher` is invoked only for non-Safari payloads and uses an ephemeral session with no persistent cookies, credential storage, or cache. It accepts 2xx responses with `text/html`, `application/xhtml+xml`, or absent MIME type; authentication challenges and other MIME types fall through to manual title. One monotonic three-second deadline includes redirects and body reads. The task cancels before accepting byte 65,537 after URLSession transfer decoding. -Character decoding order is: valid HTTP `charset`, BOM, valid `<meta charset>` found in the first 1,024 bytes, UTF-8, then Windows-1252. If none decode, title acquisition fails. The bounded scanner stops at `</head>` or the byte limit, matches tags/attributes ASCII-case-insensitively across chunk boundaries, handles quoted/unquoted attributes, and returns the first nonblank `<title>` plus all canonical href candidates in document order. Title text is decoded through `HTMLEntityDecoder` — numeric references plus the HTML 4 named set; unknown named entities remain literal. (Originally the five XML predefined names only; widened by T-2116, whose bugfix report records why.)+Character decoding order is: valid HTTP `charset`, BOM, valid `<meta charset>` found in the first 1,024 bytes, UTF-8, then Windows-1252. If none decode, title acquisition fails. The bounded scanner stops at `</head>` or the byte limit, matches tags/attributes ASCII-case-insensitively across chunk boundaries, handles quoted/unquoted attributes, and returns the first nonblank `<title>` plus all canonical href candidates in document order. Title text and attribute values (so a canonical `href` carrying `&`) are decoded through `HTMLEntityDecoder` — numeric references plus the HTML 4 named set; unknown named entities remain literal. (Originally the five XML predefined names in title text only; widened to the full set by T-2116 and to attribute values by T-2119, whose bugfix reports record why.) Canonical resolution uses the final response URL as base. A candidate is valid only when Foundation resolves it to an absolute HTTP(S) URL with a nonempty host; fragments are retained. The stored canonical string is the resolved URL’s `absoluteString`. Raw URL always remains the selected Safari/provider string.
A page captured before this fix holds …&… as its canonical string; re-sharing it after the fix yields …&…. Confirm duplicate detection falling back to the shared URL is acceptable for that small set, as the report's quick decision assumes.
Four doc-only files changed during review and swift build was re-run; nothing was committed or pushed per the caller's instruction.