oficina / docs Public

Define canonical link destinations and export parity fixtures

Paul Campbell pushed by paul@keymail.dev e6cc4e917100d9a349a46eaa92825eed26755de2 parent 293ccd0
2 files changed, +366 −9
  • implementation/link-destinations.json +235 −0
  • implementation/link-roundtrip.md +131 −9

CI failed — run details

diff --git a/implementation/link-destinations.json b/implementation/link-destinations.json
new file mode 100644
index 0000000..6f2c5dd
--- /dev/null
+++ b/implementation/link-destinations.json
@@ -0,0 +1,235 @@
+{
+ "status": "UNAPPROVED design fixtures; no product implementation",
+ "cases": [
+ {
+ "id": "http-basic",
+ "input": "http://example.test",
+ "canonical": "http://example.test/"
+ },
+ {
+ "id": "scheme-host-port",
+ "input": "HTTPS://EXAMPLE.TEST:443/a/../b",
+ "canonical": "https://example.test/b"
+ },
+ {
+ "id": "ipv6",
+ "input": "https://[2001:db8::1]:8443/a",
+ "canonical": "https://[2001:db8::1]:8443/a"
+ },
+ {
+ "id": "userinfo",
+ "input": "https://u:p@example.test/x",
+ "canonical": "https://u:p@example.test/x"
+ },
+ {
+ "id": "encoded-separators",
+ "input": "https://example.test/a%2fb%25%5C%09",
+ "canonical": "https://example.test/a%2fb%25%5C%09"
+ },
+ {
+ "id": "parentheses",
+ "input": "https://example.test/a(b)c",
+ "canonical": "https://example.test/a(b)c"
+ },
+ {
+ "id": "path-brackets",
+ "input": "https://example.test/a[b]",
+ "canonical": "https://example.test/a%5Bb%5D"
+ },
+ {
+ "id": "entity-query",
+ "input": "https://example.test/?x=1©=2&y=a+b",
+ "canonical": "https://example.test/?x=1©=2&y=a+b"
+ },
+ {
+ "id": "angle-quotes",
+ "input": "https://example.test/<x>\"",
+ "canonical": "https://example.test/%3Cx%3E%22"
+ },
+ {
+ "id": "unicode",
+ "input": "https://b\u00fccher.example/\u96ea?q=caf\u00e9#\u7ae0",
+ "canonical": "https://xn--bcher-kva.example/%E9%9B%AA?q=caf%C3%A9#%E7%AB%A0"
+ },
+ {
+ "id": "nbsp-path",
+ "input": "https://example.test/a\u00a0b",
+ "canonical": "https://example.test/a%C2%A0b"
+ },
+ {
+ "id": "line-separator-path",
+ "input": "https://example.test/a\u2028b",
+ "canonical": "https://example.test/a%E2%80%A8b"
+ },
+ {
+ "id": "percent-space",
+ "input": "https://example.test/a%20b",
+ "canonical": "https://example.test/a%20b"
+ },
+ {
+ "id": "mailto",
+ "input": "MAILTO:User+tag@example.test",
+ "canonical": "mailto:User+tag@example.test"
+ },
+ {
+ "id": "mailto-body",
+ "input": "mailto:a@example.test?subject=Hello%20there&body=one%0D%0Atwo",
+ "canonical": "mailto:a@example.test?subject=Hello%20there&body=one%0D%0Atwo"
+ },
+ {
+ "id": "mailto-unicode",
+ "input": "mailto:a@example.test?subject=caf\u00e9",
+ "canonical": "mailto:a@example.test?subject=caf%C3%A9"
+ },
+ {
+ "id": "mailto-query-only",
+ "input": "mailto:?body=x%26y&subject=a+b&subject=c",
+ "canonical": "mailto:?body=x%26y&subject=a+b&subject=c"
+ },
+ {
+ "id": "mailto-empty",
+ "input": "mailto:",
+ "canonical": "mailto:"
+ },
+ {
+ "id": "suffix-tab",
+ "input": "https://example.test/a\tb",
+ "canonical": null
+ },
+ {
+ "id": "suffix-lf",
+ "input": "https://example.test/a\nb",
+ "canonical": null
+ },
+ {
+ "id": "suffix-cr",
+ "input": "https://example.test/a\rb",
+ "canonical": null
+ },
+ {
+ "id": "suffix-null",
+ "input": "https://example.test/a\u0000b",
+ "canonical": null
+ },
+ {
+ "id": "suffix-del",
+ "input": "https://example.test/a\u007fb",
+ "canonical": null
+ },
+ {
+ "id": "raw-space",
+ "input": "https://example.test/a b",
+ "canonical": null
+ },
+ {
+ "id": "leading-space",
+ "input": " https://example.test/",
+ "canonical": null
+ },
+ {
+ "id": "leading-nbsp",
+ "input": "\u00a0https://example.test/",
+ "canonical": null
+ },
+ {
+ "id": "scheme-tab",
+ "input": "ht\ttps://example.test",
+ "canonical": null
+ },
+ {
+ "id": "scheme-relative",
+ "input": "https:relative",
+ "canonical": null
+ },
+ {
+ "id": "one-slash",
+ "input": "https:/example.test",
+ "canonical": null
+ },
+ {
+ "id": "three-slashes",
+ "input": "https:///example.test",
+ "canonical": null
+ },
+ {
+ "id": "empty-authority",
+ "input": "https://?x",
+ "canonical": null
+ },
+ {
+ "id": "protocol-relative",
+ "input": "//example.test/x",
+ "canonical": null
+ },
+ {
+ "id": "relative",
+ "input": "../x",
+ "canonical": null
+ },
+ {
+ "id": "backslash",
+ "input": "https://example.test/a\\b",
+ "canonical": null
+ },
+ {
+ "id": "authority-backslash",
+ "input": "https:\\example.test",
+ "canonical": null
+ },
+ {
+ "id": "bad-percent",
+ "input": "https://example.test/%zz",
+ "canonical": null
+ },
+ {
+ "id": "trailing-percent",
+ "input": "https://example.test/%",
+ "canonical": null
+ },
+ {
+ "id": "bad-port",
+ "input": "https://example.test:70000/",
+ "canonical": null
+ },
+ {
+ "id": "bad-host",
+ "input": "https://[broken]/",
+ "canonical": null
+ },
+ {
+ "id": "javascript",
+ "input": "javascript:alert(1)",
+ "canonical": null
+ },
+ {
+ "id": "data",
+ "input": "data:text/html,hi",
+ "canonical": null
+ },
+ {
+ "id": "blob",
+ "input": "blob:https://example.test/id",
+ "canonical": null
+ },
+ {
+ "id": "mailto-raw-body-newline",
+ "input": "mailto:a@example.test?body=a\nb",
+ "canonical": null
+ },
+ {
+ "id": "mailto-authority",
+ "input": "mailto://a@example.test",
+ "canonical": null
+ },
+ {
+ "id": "mailto-fragment",
+ "input": "mailto:a@example.test#x",
+ "canonical": null
+ },
+ {
+ "id": "unpaired-surrogate",
+ "input": "https://example.test/\ud800",
+ "canonical": null
+ }
+ ]
+}
diff --git a/implementation/link-roundtrip.md b/implementation/link-roundtrip.md
index 41d087b..d3fae20 100644
--- a/implementation/link-roundtrip.md
+++ b/implementation/link-roundtrip.md
@@ -1,5 +1,9 @@
# Link round-trip execution slice
+Revision 2 — UNAPPROVED design correction; no product implementation authorized
+by this draft. Supersedes only the URL paragraph in frozen293ccd0 after the
+independent Codex review recorded in Docs44. Original Fable job stays frozen.
+
Agent implementation specification, based on origin/main 87662f5 and the existing
Plan 7 D1/D3. The unmerged revision-3 plan at c1ac560 is supporting analysis,
not a merged ruling. This slice implements pasted links end to end in the read
@@ -14,15 +18,11 @@ slice and its existing disabled commands must not be represented as complete.
consumed by both HTML and JSON. Only link may carry a value; unknown mark
names retain current escaped name-only behavior. Reject malformed/non-string
href to unlinked text, preserving other marks and all text.
-- Adopt Plan 7 positive ASCII scheme grammar and case-insensitive http, https,
- mailto allowlist. No trimming/relative URL inference. Reject leading controls,
- embedded scheme controls, protocol-relative, data, blob and javascript forms.
- Preserve accepted href content and escape it appropriately at each output
- syntax boundary. HTML uses href plus rel=noreferrer, no target or title.
-- Exports consume only normalized core link attributes. Markdown destination
- encoding must preserve URL meaning while preventing delimiter/control injection;
- a dangerous URL must never become a mark again downstream. Preserve formatting
- and text escaping, including adjacent differently marked runs.
+- Apply the accepted-shape and normalization contract below once in the shared
+ core, before both HTML and JSON. Exports consume that normalized href only.
+ Rejection drops only the link mark; text and other marks survive unchanged.
+- HTML uses the canonical href plus rel=noreferrer, with no target or title.
+ Markdown and PDF serialize the same canonical destination as specified below.
- PDF emits actual URI annotations for every wrapped visible linked text segment,
aligned to its page and drawn geometry, preserving baseline and line layout.
URI PDF string/object encoding must resist parentheses, backslashes, line breaks
@@ -49,3 +49,125 @@ canary evidence are required. Shared hosts receive only merged verified main.
Review question: identify any unsafe ambiguity or architectural mismatch before
implementation, especially Markdown URL encoding and PDF annotation geometry.
+
+## Accepted shape and one normalization
+
+This is a proposed bounded amendment to Plan7 D1, not an already-attributed
+product ruling: the positive scheme check remains necessary but is no longer
+sufficient. It previously admitted suffix controls and base-dependent HTTP
+forms. Those become unlinked text, not repaired URLs. Accepted spelling may
+change through the one canonicalization below; the earlier promise to preserve
+accepted input bytes is replaced by preserving the canonical destination across
+outputs. No insertion UI, target-fetch policy, or other scheme is added.
+
+The shared core applies these steps in order to the original string:
+
+1. Require valid Unicode scalar values and the existing anchored ASCII scheme
+ grammar `^[A-Za-z][A-Za-z0-9+.-]*:`; allow only http, https and mailto after
+ ASCII case folding. Never trim, prepend a scheme, or supply a base.
+2. Reject any raw U+0000–U+0020, U+007F, or backslash anywhere in the string,
+ including after the colon. Reject every `%` not followed by two ASCII hex
+ digits. Do not percent-decode the string before these tests. Valid `%20`,
+ `%09`, `%0D%0A`, `%25`, `%2F` and `%5C` remain encoded data, not raw syntax.
+ Non-ASCII characters after the scheme are allowed and serialized below;
+ this includes NBSP/U+2028 in a path, not before the scheme. Do not Unicode
+ normalize, case-fold paths, or convert plus to a space.
+3. HTTP(S) must have literal `://` immediately after the scheme and a nonempty
+ authority substring before the first `/`, `?` or `#`. Thus `https:relative`,
+ `https:/host`, `https:///host` and `https://?x` are refused before parsing.
+ Parse with the WHATWG URL algorithm without a base, require success and a
+ nonempty host with the same allowed scheme. Its host/IPv4/IPv6/port parsing
+ is authoritative; no separate hand-written host grammar. Existing userinfo,
+ ports, fragments and query ordering are retained under that algorithm.
+4. Mailto must be non-hierarchical: the suffix cannot begin `//`, and cannot
+ contain a raw `#` (mailto has no fragment component). The suffix consists
+ of opaque recipient text, optionally followed by `?` and opaque header text;
+ empty recipient text and `mailto:` remain allowed. No mailbox deliverability
+ check or header allowlist is introduced. Parse without a base and require an
+ opaque path and scheme mailto. Preserve recipient case, header order,
+ duplicate headers, plus signs, existing percent escapes and encoded body
+ line breaks. This is URI fidelity, not a guarantee of what a mail client
+ will send or display. Do not use form-urlencoded/URLSearchParams decoding.
+5. Serialize the parsed URL. On that serialization only, percent-encode UTF-8
+ bytes outside ASCII unreserved characters plus `:/?#@!$&'()*+,;=%`;
+ preserve `[` and `]` only as the parsed HTTP IPv6 host delimiters, encoding
+ them elsewhere. Work from parsed components, not a substring search;
+ uppercase hex for newly encoded bytes, preserve existing escape spelling.
+ This makes one ASCII URI, including an ASCII IDNA host for HTTP(S), with
+ no raw whitespace, controls, backslash, angle brackets or double quote.
+ Parse/serialize this ASCII result again without a base and require the same
+ result (fixed point), scheme and HTTP host/mailto opaque-path invariants.
+ Failure means unlinked text. The resulting ASCII string is the canonical
+ href carried by normalized HTML/JSON marks; it is never written back into
+ the stored CRDT by reading or exporting.
+
+Use a maintained WHATWG implementation in the Rust core, not a partial URL
+parser or a Go/browser-specific second normalizer. The exact released crate
+and locked dependency set must be selected and tested in implementation review:
+wasm32-unknown-unknown builds, no host imports, no networking/entropy dependency,
+existing deterministic wasm gate. This draft does not approve a dependency pin.
+Canonicalization must pass the versioned fixtures and independent browser URL
+oracle; a library mismatch is a failed gate, not a reason to change fixtures.
+
+## Output encoding and parity definition
+
+Let C be the normalized ASCII href. Every output must preserve C, not re-run
+scheme policy or recover an href dropped by the core.
+
+- HTML: escape C once as an attribute value. The parsed DOM attribute must be
+ exactly C and its resolved `a.href` must equal C with either of two unrelated
+ document bases. Escaping ampersands/quotes is HTML syntax only.
+- Markdown: emit an angle-delimited destination `[label](<C>)`. In that source
+ destination replace each literal `&` with `&amp;` once, so a URI containing
+ `&copy;` is not decoded into a different query. C contains no raw angle
+ brackets, line breaks or backslashes; parentheses are harmless inside the
+ angle-delimited destination. Never percent-encode existing `%` again.
+ Exception: for an HTTP(S) IPv6 host, emit a raw inline HTML anchor with
+ the same escaped href/rel contract as the HTML reader, surrounding the
+ normally rendered inline Markdown label. CommonMark raw inline HTML leaves
+ inline emphasis/code processing active; a link label cannot create nested
+ anchors. This fallback is required by actual reference-parser evidence:
+ both commonmark-js0.31.2 and markdown-it14.3.0 percent-encode IPv6 host brackets
+ in ordinary link destinations, producing an invalid browser destination.
+ Do not reject otherwise-valid IPv6 links or percent-encode their authority.
+ Raw HTML is generated from canonical C and escaped text, never copied from
+ a mark value. This export requires a CommonMark reader with raw HTML enabled;
+ readers configured to strip HTML may retain the label without a link. That
+ limitation is explicit; no Markdown export can promise all sanitizer modes.
+ An independent CommonMark parser must produce one link whose DOM attribute
+ and resolved destination equal C, including labels with code/emphasis and
+ leading/trailing whitespace. Preserve text/mark rendering and distinguish
+ adjacent links or adjacent differently marked runs; no nested links.
+- PDF: store C as the URI action's ASCII byte string. Escape only PDF string
+ delimiters/backslashes (or use a hex string), never URL-decode or re-encode C.
+ Parse the actual PDF objects with an independent reader: decoded `/URI` bytes
+ must equal ASCII(C), not a substring somewhere in the output. There is no
+ document-base dependency. Use the existing draw pass for annotation rectangles
+ after line/page/alignment resolution, never a second measuring pass.
+
+Parity is exact equality of normalized C, decoded HTML destination, independently
+parsed Markdown destination, and decoded PDF URI bytes, plus base independence
+for the first two. It is not equality of display labels, browser address-bar
+presentation, network redirects, remote content, or mail-client behavior. Never
+fetch any fixture target. Rejected cases must produce no anchor, no normalized
+link JSON attribute and no PDF URI action while retaining text and other marks.
+
+`implementation/link-destinations.json` is the normative synthetic corpus. The
+fixture label/mark matrix also includes linked code, whitespace-only/edge-space
+runs, adjacent strong/plain runs, parentheses, brackets, ampersands and Unicode
+labels. PDF geometry adds mixed linked/unlinked runs, split long words, wrapped
+lines, center/right alignment, list/quote indentation and page boundaries.
+HTML and JSON rejection tests must execute independently; the inherited RED
+HTML assertion must not prevent observing the JSON rejection path.
+
+Implementation acceptance runs this corpus through real y-prosemirror→Yjs→core
+HTML/JSON→Go Markdown/PDF, then independent syntax readers and offline browser
+URL resolution. A local design-oracle check can validate fixture expectations;
+it is explicitly not proof that those currently-missing product paths work.
+
+Primary references consulted 2026-09-08: WHATWG URL parsing/serialization
+https://url.spec.whatwg.org/ (living standard dated 2026-08-18), CommonMark0.31.2
+link destinations/entities https://spec.commonmark.org/0.31.2/, and RFC6068
+mailto percent encoding/query syntax https://www.rfc-editor.org/rfc/rfc6068.
+The raw-control, explicit-authority and backslash restrictions above are this
+proposed application contract, not claims that the standards reject them all.