# Canonicalization profile `alltio-text-2`

A record registers a **work**, not a page. This profile defines how the canonical
text of a work is derived from HTML, and how that text is hashed, so that a page
can carry its own mark, change its footer, gain a nav item or swap its analytics
without invalidating the record filed against it.

Every record stores the profile it was filed under, and the lookup response
echoes it. Verification runs under the profile the record declares. A future
`alltio-text-3` therefore re-registers nothing — without that, every
normalization fix is a migration.

`alltio-text-2` supersedes `alltio-text-1` (decided 20 September 2026, before
either was live): typographic variants are folded before hashing. The reason
is the standing acceptance test below — platforms rewrite straight quotation
marks into curly ones at the moment of publishing, and a rule that preserves
that difference cannot pass its own test. Records filed under `alltio-text-1`
were recomputed under this profile; none remain.

This profile, its conformance vectors, and the normative module are published:

- Rule (this document): `https://alltio.com/assets/alltio-text-2.SPEC.md`
- Conformance vectors: `https://alltio.com/assets/alltio-text-2.vectors.json`
- Normative module: `https://alltio.com/assets/alltio-text-2.js`

Anyone holding the same text arrives at the same value, with no Alltio code
beyond this published module.

## Scope

The fingerprinted region is declared, not inferred. The element carrying
`data-alltio-body` is the work; everything outside it is chrome.

Selection **fails closed**. Zero matches or more than one is an error — never a
fallback to `<article>`, to `<main>`, or to the largest text block. A registry
that guesses at scope produces records nobody can reproduce.

Anything inside the region carrying `data-alltio-mark` is excluded with its
subtree. That is what lets the visible "Registered with Alltio" line be added
after registration without moving the digest, and it is how boilerplate that is
not the work — an author bio, a subscribe prompt — stays out of the hash.

## Algorithm

1. **Parse as HTML5.** Not with regular expressions.
2. **Select** the `data-alltio-body` subtree. Exactly one, or error.
3. **Remove subtrees:** any element carrying `data-alltio-mark`, plus `script`,
   `style`, `template`, `noscript`. **Comment nodes are also removed.**
4. **Decode character references.** `&mdash;` becomes U+2014. A conforming HTML5
   parser has already done this.
5. **Extract text.** Block-level elements (list below) emit `\n` **on open and
   on close**. `<br>` emits `\n`. Inline elements contribute nothing of their
   own; only text nodes contribute characters.
6. **Unicode NFC.** Not NFKC, which folds ligatures and superscripts and would
   erase real distinctions.
7. **Fold typographic variants.** Curly quotation marks and apostrophes,
   including prime characters, fold to the plain typewriter forms
   (U+2018 U+2019 U+201A U+201B U+2032 U+2035 → `'`;
   U+201C U+201D U+201E U+201F U+2033 U+2036 → `"`). En dashes, em dashes,
   and minus signs fold to a single hyphen (U+2013 U+2014 U+2212 → `-`). An
   ellipsis character becomes three periods (U+2026 → `...`). Soft hyphens
   and zero-width characters are removed
   (U+00AD U+200B U+200C U+200D U+2060 U+FEFF). Nothing else changes: case
   is preserved, spelling is preserved, and no word or punctuation mark is
   removed.
8. **Whitespace.** Within each line, collapse runs of Unicode whitespace to a
   single U+0020; trim each line; drop empty lines; join with `\n`; no trailing
   newline. Non-breaking and other unusual spaces are in the whitespace class
   below, so they become ordinary spaces before the collapse.
9. **Encode UTF-8 without BOM.** SHA-256 over those bytes. Digests are lowercase
   hex.

### Why step 5 breaks on open as well as close

Closing alone is not enough. In `<li>Two<ul><li>Inner</li></ul></li>` the inner
list opens directly after text, and a close-only rule yields `TwoInner` — two
words silently welded together inside the digest. Because step 8 drops empty
lines, breaking on open as well is free everywhere it is not needed.

### Block-level elements

Pinned here rather than inferred from CSS, since a verifier has no stylesheet:

```
address article aside blockquote dd details dialog div dl dt fieldset
figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr li main
nav ol p pre section table tbody td tfoot th thead tr ul
```

### Whitespace, enumerated

```
U+0009 U+000A U+000B U+000C U+000D U+0020 U+0085 U+00A0 U+1680
U+2000–U+200A U+2028 U+2029 U+202F U+205F U+3000
```

Enumerated rather than delegated to a regex `\s` class, because those classes
disagree across languages — JavaScript's includes U+FEFF, Python's does not —
and a disagreement here is a digest mismatch on a real record. U+FEFF is
deliberately **not** whitespace here; it is a format character, removed
wherever it appears by step 7's zero-width rule.

## Text mode

Registration also happens from a file and from an editor buffer, where there
is no HTML and no scope to select because the caller asserts "this text IS the
body". `canonicalizeText` / `digestText` apply steps 6 and 8 to the given
text directly.

The contract this preserves (registration contract, 16 Sep 2026): the
canonical text of a work is the **body prose only**. The title, standfirst,
byline, publication date, and the Alltio mark are record metadata and never
enter the fingerprint — a hash that included the title depended on where each
platform's template renders it. The standing acceptance test is that the same
article registered from a file, from an editor at the moment of publishing,
and from an already-published URL produces one identical fingerprint
(`textVectors` in vectors.json pin it at unit level;
functions/test/canonical-selftest.js pins it end to end).

## What is and is not covered

Covered: the text of the work, including figure captions, which are authored
argument.

Not covered: images. A text hash says nothing about image bytes, and this
profile does not pretend otherwise. An image is a second asset under the same
work, registered when multi-asset lands. A record filed under this profile
covers the text and says so.

## Conformance

`vectors.json` is the conformance suite: input HTML, expected canonical text,
expected digest. An implementation is conformant when every vector matches
exactly and every error vector throws.

```
node test.js
```

The suite pins the cases that prose does not settle — comments, nested and
inline exclusions, `<br>` inside a paragraph, entity-heavy text, NFC versus
NFKC, quote preservation, mixed whitespace, and both fail-closed errors.

An independent implementation conforms by matching the published vectors,
not by reading this module: the vectors pin every folding case alongside the
structural ones. (Under `alltio-text-1`, two independent implementations —
JavaScript over linkedom, Python over html5lib — produced byte-identical
output for a reference page; the same check is the acceptance bar for any
second implementation of this profile.)

## Using the module

`index.js` has no dependencies and no opinion about how HTML becomes a DOM. It
takes a `Document`, so a browser verifier uses `DOMParser` and ships nothing
extra; Node supplies its own parser.

```js
import { digest } from './index.js';

const doc = new DOMParser().parseFromString(html, 'text/html');
const { profile, text, sha256 } = await digest(doc);
```

The register flow and the lookup API must import this same module. Two
implementations of a rule this fiddly will drift, and drift produces a false
negative on verification — a genuine record failing its own check, which is the
expensive direction of failure for a provenance registry.
