# headless-seo-check — decision records

Dated, immutable records of the decisions that shaped the CLI. An entry is
never edited after the fact: a change of mind gets a **new** entry that names
the one it supersedes. `ELEVA-nnn` references are Linear issues. The current
state of the contract always lives in `docs/contract.md`; this file is the
history of how it got there.

ADR-001 through ADR-010 were taken while writing the original conformance
specification and porting the JavaScript prototype to Rust (archived verbatim
in `docs/history/cli-conformance.md`, where ADR-001 to ADR-007 appear as
D1–D7). Later entries correspond to the audit issues that followed.

---

## ADR-001 — The site stays a positional argument

- **Date:** 2026-08-08 · **Status:** accepted

**Decision.** Exactly one positional argument, mandatory, forever; two
positionals are refused.

**Context.** clig.dev prefers named flags, but its own carve-out applies: a
single argument that is the primary object of a simple action is idiomatic
(`curl <url>`, `ping <host>`). `--site example.com` adds typing and zero
clarity, and no second positional could ever be confused with it.

**Consequences.** The command reads as a sentence. Any future second input
must be a flag.

## ADR-002 — `-v` stays a hidden alias for `--version`

- **Date:** 2026-08-08 · **Status:** superseded by ADR-014

**Decision.** `-V` is version; `-v` remains a hidden compatibility alias for
version; `--verbose` gets no short form.

**Context.** The JavaScript prototype shipped `-v` = version (undocumented,
but shipped); reassigning the letter would silently change behaviour for
existing users — the worst kind of break.

**Consequences.** The premise turned out to be empty: the only scripts
concerned targeted the never-published prototype. See ADR-014.

## ADR-003 — `--verbose` is a human-mode rendering switch

- **Date:** 2026-08-08 · **Status:** accepted, amended by ADR-021

**Decision.** `--verbose` exists, and changes only the human rendering: full
URLs, kind slugs, link counts and TTFB, unfolded redirect/non-HTML rows. It
adds nothing to `--plain`, `--json` or stderr and never changes the exit
code.

**Context.** The original specification promised classification evidence on
stderr in machine modes. As shipped, that evidence — the cache headers a
verdict rests on, the second-request outcome, the hreflang maps — already
travels in the findings' `detail` lines, in every mode. Printing it again on
stderr would duplicate the report, not add to it.

**Consequences.** Machine outputs are identical with and without
`--verbose`. The second half of that sentence — that there was no way to see
the cache headers of a page that passed — was a bug, not a decision, and
ADR-021 fixes it inside the human report the switch already owns.

## ADR-004 — The `DEBUG` environment variable is deliberately ignored

- **Date:** 2026-08-08 · **Status:** accepted

**Decision.** `DEBUG` never changes the tool's behaviour; verbosity is
opt-in per invocation via `--verbose` only.

**Context.** clig.dev suggests honouring it, but `DEBUG` is a wildcard
commonly exported globally in CI for *other* tools. A diagnostic whose
output contract changes because of an ambient variable is not reproducible.

**Consequences.** Identical command lines produce identical behaviour across
machines.

## ADR-005 — No configuration file, no `.env`, no XDG

- **Date:** 2026-08-08 · **Status:** accepted

**Decision.** The entire configuration is the command line.

**Context.** The tool's primary home is CI, where the workflow file *is* the
versioned project config. A user-level config file would make the same
command line produce different results on different machines — fatal for a
diagnostic whose output people paste into bug reports.

**Consequences.** If a config file is ever added it must be project-level,
opted into via an explicit flag, and the contract revised first.

## ADR-006 — No pager

- **Date:** 2026-08-08 · **Status:** accepted

**Decision.** Output is never piped to `$PAGER`.

**Context.** Default output is bounded by `--sample` (a screenful). Users
who run `--all` on hundreds of URLs are exactly the users piping to a file
or `less` themselves; auto-invoking a pager would complicate the CI path for
no interactive gain.

**Consequences.** `PAGER` joins `DEBUG` in the deliberately-ignored list.

## ADR-007 — Interruption exits 130 and still prints the partial report

- **Date:** 2026-08-08 · **Status:** accepted — implemented by ADR-011

**Decision.** Ctrl-C must not discard completed work: the report over the
pages already checked is written, and the exit code is 130 (128+SIGINT, the
shell convention) — never 0, because a partial clean scan is not a pass.

**Context.** Killing a 350-URL scan at page 200 threw away 200 requests'
worth of results in the prototype.

**Consequences.** `0.1.0` shipped the report-side plumbing but nothing
observed the signal (stated in the conformance document rather than hidden);
ADR-011 wired it.

## ADR-008 — The fatal report goes to stdout, not stderr

- **Date:** 2026-08-08 · **Status:** accepted — reverses the original
  specification (§2.2 of the archived conformance document)

**Decision.** The dividing line is *did a run happen at all*. A usage error
produced no run: stdout stays empty (except under `--json`, which always
writes an envelope). A run that started and could not finish **is** a
result: the fatal report goes to stdout in the requested mode, with the
one-line reason echoed to stderr.

**Context.** The specification originally routed the whole fatal block to
stderr so that `tool > report.txt` on a dead host would not leave the
terminal blank. That concern is met by the stderr echo — and only the
stdout placement lets `--json` keep its promise that stdout is always a
parseable document, and `--plain` state `summary⇥exit_code⇥2`.

**Consequences.** A redirected report file is always complete and
self-contained, whether the run succeeded or not.

## ADR-009 — `--plain` records are typed; there is no `ok` record

- **Date:** 2026-08-08 · **Status:** accepted — reverses the original
  specification (§2.5 of the archived conformance document)

**Decision.** Every `--plain` line starts with its record type (`run`,
`page`, `finding`, `detail`, `summary`), each type with a fixed field count.
A page with no findings emits only its `page` record; the per-page `ok`
line the specification described does not exist.

**Context.** The original four bare untyped columns made
`awk '$1=="finding"'` impossible and left `--plain` nowhere to state its own
blocking count or exit code — so the tool's central invariant (all modes
agree on what is blocking) had no expression there and could not be tested
there.

**Consequences.** The grammar is self-describing and additive-only; a
parser checks a record by its type and field count.

## ADR-010 — A bare host on the loopback assumes `http`

- **Date:** 2026-08-08 · **Status:** accepted — revises the original
  specification (§2.1 of the archived conformance document)

**Decision.** A schemeless host gets `https`, except on the loopback
(`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`) where it gets `http`. An
explicit scheme always wins.

**Context.** The blanket-`https` rule made the first example printed by the
tool's own `--help` fail — no dev server speaks TLS on `localhost:3000`.
Guessing `http` for a public host would be the worse error (it silently
downgrades a real site), so the exception stops exactly at the loopback and
`.localhost` (RFC 6761).

**Consequences.** The `--help` examples work as printed; public hosts are
never downgraded.

## ADR-011 — SIGINT is wired: the partial report and exit 130 are real (ELEVA-294)

- **Date:** 2026-08-08 · **Status:** accepted — implements ADR-007

**Decision.** The first Ctrl-C stops scheduling new requests, awaits the
requests already in flight, and lets the normal flow emit the partial report
with exit 130. A second Ctrl-C abandons the scan with an empty stdout. An
interrupted run whose checked pages were all unreachable stays 130, not a
fatal 2. SIGTERM/143 remains deliberately unobserved: a constant no code
path can reach is a promise the program does not keep.

**Context.** The help text had advertised 130 since the beginning, while
nothing could observe a signal — the one section of the contract `0.1.0` did
not satisfy.

**Consequences.** In-flight requests are awaited, not aborted, so the first
Ctrl-C is bounded by `--timeout` rather than the ~1 s the original
specification asked; the second Ctrl-C is the fast way out.

## ADR-012 — hreflang targets are verified off-origin, and the run says so (ELEVA-295)

- **Date:** 2026-08-08 · **Status:** accepted

**Decision.** Target verification is the single exception to the rebase
invariant: a target is probed where it lives, other origins included, and
the run announces (as a note, on stderr and in JSON `notes`) how many
targets were actually probed off the audited origin — origin meaning scheme,
host **and** port. `--no-verify` is the opt-out.

**Context.** A target rebased onto the audited origin would answer for a URL
the site never advertised, so it would prove nothing; and multi-domain
alternate sets are the normal form of international hreflang. But this is
the only traffic the tool ever sends somewhere other than the site named on
the command line, and it was silent.

**Consequences.** The rebase invariant is scoped precisely (audited URLs),
the exception is bounded (headers only, 12 targets/run, 5 hops) and visible.

## ADR-013 — A blank static page is mentioned, at Info (ELEVA-297)

- **Date:** 2026-08-08 · **Status:** accepted

**Decision.** A page serving exactly zero words with no JavaScript to
explain the emptiness gets `no-server-text` at Info — never blocking, never
fired alongside `empty-server-html`, still nothing when the parser gave up.

**Context.** The CI compromise (thin static pages pass) had swallowed a real
fact: a zero-word, zero-script page is not a short page, it is a soft 404.
Saying nothing suppressed information rather than withholding judgement.

**Consequences.** The fact is reported; exit codes and verdicts do not
change. The threshold is exactly zero — a one-word page still passes silent.

## ADR-014 — `-v` now means `--verbose` (ELEVA-298)

- **Date:** 2026-08-08 · **Status:** accepted — supersedes ADR-002

**Decision.** `-v` is `--verbose`, as POSIX, GNU and Cargo spell it. `-V`
and `--version` are untouched; the hidden version alias is gone.

**Context.** ADR-002 protected scripts written against the JavaScript
prototype — which was never published, so no Rust release ever served `-v`
as version and the compatibility being protected was never at risk. The
break lands in `0.1.0`, before any adoption.

**Consequences.** One breaking change, taken at the only moment it costs
nothing. The letter now follows the convention every user expects.

## ADR-015 — The verification budget stays non-deterministic beyond its cap (ELEVA-300)

- **Date:** 2026-08-08 · **Status:** accepted — scope widened by ADR-018

**Decision.** Which drifted targets get the 12-per-run verification budget
depends on response arrival order, and this is accepted rather than fixed.
The run announces a spent budget — how many drift targets went unchecked —
through the note channel; nothing is counted under `--no-verify`.

**Context.** Making the attribution order-independent would move
verification after the page loop, and the live progress counter would then
undercount the blocking findings the final summary reports. The equality
between the running count and the summary is the stronger contract; the
non-determinism only appears beyond 12 drifts per run.

**Consequences.** Two identical runs may differ in *which*
`hreflang-dead-target` they report past the cap — visibly, never silently:
the note says a dead target among the unchecked could go unreported.

## ADR-016 — CJK characters count as words (ELEVA-299)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** Word counting adds one word per alphanumeric character in
seven CJK Unicode ranges (Hiragana, Katakana, halfwidth Katakana, CJK
ideographs, extension A, compatibility ideographs, Hangul syllables); mixed
tokens count their CJK characters plus one for the remainder. Latin counting
is unchanged to the byte, and the 60/200 thresholds did not move.

**Context.** Chinese and Japanese write no whitespace, so blank-splitting
counted a healthy CJK page as zero words — behind a bundle, that meant
`empty-server-html` and a failed build: the tool's one structural false
positive. The counting was wrong, not the thresholds.

**Consequences.** One character = one word is right for Chinese and
overcounts Japanese about twofold — the safe direction for a tool that would
rather miss a thin page than invent a shell. No language detection, no
special mode, no added dependency.

## ADR-017 — An interrupted run is badged INTERRUPTED, never PASSED (ELEVA-302)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** The human verdict of an interrupted run is `INTERRUPTED` (in
yellow — neither the green of health nor the red of failure), carrying the
same "n of m pages checked" counts as the header line, with `so far` on the
breakdown. `PASSED` and a bare `FAILED` are unreachable for an interrupted
run. The badge survives `-q`.

**Context.** A run cut short with no blocking finding wore the green
`PASSED` badge one line above an exit code of 130. The badge is the one line
CI and humans read — and under `-q` it is the only remaining trace that the
scan was cut.

**Consequences.** Nothing is concluded about pages never reached; what was
found is still shown (the blocking breakdown is unchanged), and `--plain`,
`--json` and the exit code do not change.

## ADR-018 — hreflang targets the two sources agree on are verified too (ELEVA-305)

- **Date:** 2026-08-09 · **Status:** accepted — widens ADR-015

**Decision.** Verification no longer stops at drift. A target the `Link:`
header and the HTML advertise identically is verified as well, under the same
12-per-run budget, and a dead one is the same `hreflang-dead-target` at Error.
Each page spends on its drift first and on its agreed targets with what is
left; a target equal to the page's own URL is never requested, and one URL
advertised under several language tags costs one request.

**Context.** Checking only drifted targets made `hreflang-dead-target`
structurally unreachable in the case real runs against Next.js applications
produce most: the header and the HTML come out of one generator, so a wrongly
derived URL lands in both at once. Nothing disagreed, nothing was checked, and
the page passed green with a language pointing at a 404.

**Consequences.** A healthy multi-language site now spends budget on live
targets and still reports nothing; the extra traffic is bounded by the same
cap, and what the cap did not reach is counted and announced by the exhaustion
note, whose wording stops saying "drift". The finding message no longer names
the `Link:` header, since one kind now covers both shapes — the slug, its
severity and its blocking status are untouched, as §6 of the contract
promises. The non-determinism ADR-015 accepted now applies to the wider
population, with drift served first.

## ADR-019 — A development server is announced, never failed (ELEVA-304)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** A checked page whose response carries the Next.js
development-server fingerprint — `x-powered-by` naming Next.js, `cache-control`
carrying `no-cache`, and neither `x-nextjs-cache` nor `x-nextjs-prerender` —
produces a run note on stderr and in the JSON `notes` array, naming the
production build to audit instead. All three conditions are required together.
No verdict, no finding, no exit code changes.

**Context.** Runs against a real application found the worst failure this tool
can have: ten blocking issues under a production build, `PASSED` and exit 0
under `next dev`. The dev server's headers classify as `unclear`, `unclear`
never blocks by design (`docs/design.md` §3.6), and nothing
told the user their clean report described a process nobody will ever crawl.
Auditing `localhost:3000` is the most natural first thing to try.

**Consequences.** A dev-server run is still allowed and still exits on its own
merits — the user is entitled to audit whatever they point us at, and failing
here would break every CI job that checks a preview server. The strictness of
the fingerprint is deliberate: the banner is load-bearing, so a production site
that sends `no-cache` deliberately is never accused, at the price of missing
dev servers behind a proxy that strips `x-powered-by`. The note is emitted
before the hreflang notes, because it qualifies everything else the run says.

## ADR-020 — A finite lifetime outranks a prerender marker (ELEVA-309)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** `render::classify` reads a **finite** shared lifetime — an
`s-maxage`, else a `max-age`, strictly between zero and a year, with `no-cache`
absent — before it reads any prerender marker: such a response is `isr`, even
when it also carries `x-nextjs-prerender: 1` or `x-vercel-cache: PRERENDER`. A
marker with no finite lifetime, or a lifetime of a year or more, remains
`static`. `no-store` / `private` → `dynamic` stays the absolute short-circuit,
`no-cache` still disqualifies a lifetime, and `cached` / `unclear` are
untouched. No severity moves: both modes are healthy and neither produces a
finding.

**Context.** Runs against a self-hosted Next.js application showed that
`revalidate = 60` answers with the prerender marker *and* `s-maxage=60` on the
same response. With the marker read first, every incrementally regenerated page
on that stack was labelled `static` and the `isr` verdict was unreachable — on
the commonest ISR setup there is. The module already argued the opposite
ordering one branch below, where a finite lifetime outranks `cached` because it
"says when the page will be rebuilt".

**Consequences.** `render_mode` is a frozen value set (§4.3) but not a frozen
mapping: the same site can now report `isr` where it reported `static`, which
is a visible change in `--plain` and in the JSON for anyone who pinned the
string. It is a correction, not a regression — the previous label was wrong —
and no exit code, severity or finding changes with it. The year-long `s-maxage`
keeps its `static` label because at that scale the number is a build artefact a
deploy replaces, not a revalidation window. The rule is now stated in full in
§8.11 of the contract, so the next reader does not have to derive it from the
code.

## ADR-021 — `--verbose` shows the evidence behind a healthy verdict (ELEVA-306)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** Every checked page carries the cache headers its rendering
verdict rests on, and `--verbose` prints them under the page row — but only for
the verdicts that produced no finding (`static`, `isr`, `cached`). `dynamic`
and `unclear` already carry those exact lines in their finding's `detail`, in
every mode, and hand the renderer nothing. The evidence never reaches
`--plain`, the JSON document or stderr: it travels on a `PageReport` field that
is not serialized.

**Context.** `--help` promises "the evidence behind every classification", and
ADR-003 recorded as a consequence that there was no way to see the cache
headers of a page that passed. That was a bug wearing a decision's clothes: on
a healthy scan — the common case — `--verbose` showed evidence for nothing at
all, because evidence lived exclusively in finding `detail` lines and a healthy
page produces no finding.

**Consequences.** The promise in `--help` is now true. ADR-003 stands
otherwise: `--verbose` remains a human-mode rendering switch, the machine
surfaces are byte-identical with and without it, and the human report remains
explicitly not a stable interface (§10) — which is precisely what makes this
the right surface for evidence that no consumer should learn to parse. Under
`-q` a page that passed is not printed at all, so neither is its evidence. A
page whose response carried no cache header contributes no lines rather than a
blank one. The withholding rule lives in `render`, not in `report`: the
renderer prints what it is given and needs no opinion about which finding kinds
carry evidence.

---

## ADR-022 — A path on the site argument guides discovery, never the scope (ELEVA-308)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** A path on the positional argument is used as a **discovery hint**
and never as a scope. Sitemap candidates are tried in this order:
`robots.txt` at the origin's root, then `{path}/sitemap.xml` and
`{path}/sitemap_index.xml`, then `/sitemap.xml` and `/sitemap_index.xml`. The
path is normalised first (query and fragment dropped, empty segments collapsed,
no trailing slash) and a path that reduces to `/` adds no candidate. The
homepage fallback probes the prefixed page first and the root second, and
exactly one of the two becomes the list. The audit itself stays origin-wide;
`--urls` remains the only way to narrow a run. The announcement on stderr says
which of the two happened — a path that guided discovery is not called
"ignored".

**Context.** Runs against a real Next.js build with `basePath: "/app"` found
the site unauditable. The path was dropped, discovery probed only the origin's
root, all three candidates 404'd, and the tool exited 2 with `CANNOT RUN` about
a site that was serving perfectly — while advising `--urls /app`, which audits
one page and calls it a site. A base path is an ordinary deployment, and the
URL its owner has is the prefixed one.

**Consequences.** An argument with no path produces exactly the requests it
produced before: no prefixed candidate is requested, and the "looked at …"
sentences are byte-identical. robots.txt keeps the head of the list and keeps
being fetched at the root — the web contract puts it there base path or not,
and a site that declares its own layout outranks any guess of ours, so a
robots.txt naming a working sitemap still wins over the prefix. The prefix
beats the two root paths because a `/sitemap.xml` at the root of such a
deployment more often belongs to a different application. Rebasing (§8.1) is
untouched and keeps the prefix, which is part of the path it preserves.
`discovery.source` names the sitemap actually read, prefix included, and gains
one more shape — `{url} (no sitemap found)` — for a homepage fallback that was
not the origin's root. The path is still never a scope: auditing only `/app/*`
because of how an argument was typed would report on a subset under the whole
site's name.

---

## ADR-023 — A finding that touches the whole site is said once (ELEVA-307)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** When `canonical-missing` touches **more than three** pages of a
report, the human, non-`--verbose` rendering drops the per-page warn line and
prints one summary line under the page list instead:
`!  no canonical link on {n} of {m} checked pages`, where `m` is
`summary.checked`. The threshold is the named constant `GROUP_ABOVE_PAGES` in
`report.rs`. Nothing else moves: the pages keep their `!` mark and their
status, the verdict keeps its warning count, the `{count} × {kind}` breakdown
and `hint()` are untouched, `--verbose` prints every occurrence, and
`--plain` and `--json` still emit one record per finding.

**Context.** A stock Next.js app emits no canonical anywhere, so the check
fired on fourteen of twenty-three pages of a real run. Forty identical lines
are not fourteen facts, they are wallpaper: the `noindex-meta` warning that
mattered on one page was invisible inside them. The same reasoning already
governs `hint()`, which prints its guidance once under the verdict rather than
under every occurrence — this extends the maxim from the advice to the finding
that provokes it.

**Consequences.** The human report gains a fourth block — title, pages,
grouped line, verdict — and under `-q` it stays a single unbroken run of lines,
because the blank line that separates the block is gated on the same
`!self.quiet` as every other blank. The grouping is keyed on `FindingKind`, not
on the message, so the rarer `canonical-missing` variant ("the canonical link
is not a URL that resolves") folds in with the others; `--verbose` is one
keystroke away and is what the contract already points at for detail. No other
kind is grouped today: `canonical-missing` is the only one a template emits for
a whole site at once, and a threshold applied to a finding that genuinely
varies per page would hide facts rather than repetition.

---

## ADR-024 — The human report sorts findings by severity (ELEVA-310)

- **Date:** 2026-08-09 · **Status:** accepted

**Decision.** Under each page, the human report prints findings **error, warn,
info**, sorted at render time by *effective* severity — so `--allow-dynamic`
sorts `dynamic-render` where it prints it. The sort is stable, so within one
severity the order the checks produced survives. `--plain` and `--json` keep
production order, which §5.2 now states explicitly. In the same pass, the
metrics column agrees with its numbers (`1 word`, `1 link`, never `1 words`).

**Context.** Production order is check order — render, indexability, hreflang,
content — which is an implementation fact, not a reading order. It put an
`info` above an `error` on the same page for no reason a reader could infer.
Sorting in the checks instead would have moved a human-mode concern into the
data and changed the machine formats with it.

**Consequences.** The eye reads severity, the machine reads the slug: the two
orders are now allowed to differ, and the contract says so in one line rather
than leaving it to be inferred. The rule lives in `report::human_page`, which
is the only place that renders findings for the eye, so no check has to know
about it. `-q` was examined in the same pass and left alone: every blank line
in the human report is gated on the same `!self.quiet`, so quiet output is one
unbroken block in every case — clean, warning, failing or interrupted — and the
two-line clean-site guarantee of §8.7 is that rule applied to the smallest
report, not a special case. The fatal report keeps its blank lines under `-q`
because it is a different document (§3, ADR-008), not the page-list layout.
