Changelog

What has changed, and when

Rule data goes stale quietly. This is the record that makes that visible: every change to the checks and to the scanner, dated, with the version numbers each release carries — the same two numbers printed on every report.

Scanner now
0.15.0
Rule matrix now
2026-08-04 — 9 checks, oldest review 2026-08-03, none overdue
Entries
5
Maintained by
Who runs this, and why it exists · Corrections to the rule data

Every report records the scanner version and the rule-matrix version it ran under, so a report read months from now can still be interpreted. Nothing here has been reviewed by anyone qualified to practise law, and the statute summaries are unverified — both stated in full on the pages linked above rather than in a footnote.

Every change to the rule matrix and to the scanner, newest first.

This file is append-only. Entries are not edited once written, including when they record something that later turned out to be wrong — a correction gets its own dated entry saying what was wrong and when it was fixed. A changelog that is tidied up afterwards cannot be used to work out what a report from six months ago was produced by, which is the only reason to keep one.

Two version numbers move independently and both appear on every report:

Format: ## YYYY-MM-DD — scanner X.Y.Z · rule matrix YYYY-MM-DD.

The two entries below 2026-08-04 are reconstructed from the repository history, because this file did not exist while that work was done. Their dates are the commit dates. Their rule-matrix versions are applied retroactively — no report produced before 2026-08-04 records a version at all, and every such report shows *not recorded* rather than being given one after the fact. The scanner version did not move between them: releases were not versioned separately until this entry.

2026-08-06

scanner 0.15.0 · rule matrix 2026-08-04

One dated entry covers the day, as the 2026-08-05 entry does: 0.15.0 closes the request-layer gaps, 0.14.0 is self-serve free scanning, 0.13.0 the comparison table, 0.12.0 graduated pricing and the single visual system, 0.11.0 the marketing redesign, 0.10.0 the per-site pricing, 0.9.0 the corrections the twenty-site test forced. The rule matrix does not change in any of them.

The request layer, and pinning (0.15.0)

The two gaps the adversarial review named as preconditions for public scanning.

Every request, not every navigation

The address policy applied to the URLs the walk *chose* — entry, discovered link, terms document. A page naming an internal address as an image, stylesheet, script, iframe or fetch target went out unchecked. Nothing from those is captured, so it was not a read primitive, but it was a blind request into whatever this box can reach: four other services on loopback, including a paying customer's site and the leads dashboard.

HostGuard now filters every request on the browser context, whatever its resource type, installed before any page exists so nothing can be requested ahead of the filter. Refused requests are aborted and recorded in the scan — a page naming an internal address is itself an observation, and silently dropping it would hide it.

Non-http(s) schemes are refused by default: file:, about:, chrome:, blob:, ws:, ftp:, javascript:, and whatever is invented next. data: is allowed because it carries its own bytes and reaches no network. The rule is its own method so it can be asked directly — Chromium refuses some of these on its own account before they become requests, so a test that drives a page cannot prove the rule is there.

The address validated is the address connected to

checkPublicTarget() resolved a name, decided it was public, and Chromium then resolved it again independently when connecting. A record that answered differently on the second lookup won, and won against every check above it.

Two halves, and the reason for choosing them over a proxy is in MIGRATION.md:

  • The target host is pinned in the browser's resolver. --host-resolver-rules=MAP host addr with the address it validated at, so Chromium does not resolve that name at all. There is no second lookup. This is the host a visitor supplies — the only one an attacker controls.
  • Every other host is pinned across the scan and refused if its answer changes under us. Strict equality of the answer set, not overlap: a name that starts answering with an extra address mid-scan is doing something nobody asked it to, and the cost of refusing is one failed scan.

The resolver is injectable so a test can make a name answer differently between calls. There is no way to make a real resolver flip on demand from inside a test, and a pinning mechanism with no test for the thing it prevents is a comment.

A blanket exemption the test forced open

allowPrivateTarget — which lets the demo fixtures and the acceptance suite scan this server's own loopback port — was a boolean that switched the address policy off for every host a page then referenced. A fixture naming 127.0.0.1:3000 as an image source would have been fetched. It now permits exactly one host, the entry. Found while writing the subresource test: the test could not tell "the site is allowed" from "everything is allowed", because nothing did.

Report ids

Date.now() plus six base-36 characters from Math.random() became crypto.randomBytes(16). Half of the old id was the wall clock, guessable to the second by anyone who knew roughly when a scan ran; the other half was a PRNG with no security claim. Once scanning is self-serve, that URL is the only thing protecting a report of a stranger's checkout, screenshots included.

Old ids keep working: nothing anywhere parses an id — scanDirFor() joins it to a path — so stored reports and each monitor's lastReportIds resolve exactly as before, and a test asserts both shapes load. Reports remain noindex, absent from the sitemap, and unlisted on any public page.

Still open

The migration is not done: the droplet address had not been supplied when this was written, and ARL_SELF_SERVE stays unset. A theoretical race remains on third-party hosts — a CDN a page pulls a script from is validated and pinned, but its first resolution and its first connection are still two operations. Closing that needs a connect-time proxy; the trade-off is written up rather than left implicit.

Self-serve free scanning (0.14.0)

This removes the password that was the primary abuse control on this tool. Everything below is what replaces it. The feature is off unless ARL_SELF_SERVE=1, and it is off on the deployed instance, blocked by its own prerequisite check — see "Not switched on" at the end.

The prerequisites are demonstrated, not assumed

evaluateReadiness() runs once before the listener opens and refuses to activate unless https is configured, the SSRF guard refuses a loopback address when handed one, the queue runs exactly one scan at a time, the memory guard returns a real reading, and the crawler exposes a robots token a site can name. Whichever one blocked it is named in the startup log.

Each check exercises the thing it is checking. A check that confirmed a module had been imported would pass on a build where the guard had been commented out. selfServeActive() returns false until the evaluation completes, so there is no window during boot where the feature is on because nobody has asked yet.

Four controls, because each one has a bypass

A per-email limit is defeated by a second address. A per-IP limit is defeated by a different connection. Neither protects the site being scanned, which is the party with the most to lose and the one who never agreed to any of this — that is what the per-target-domain cooldown is for, and it applies no matter who asks or how many identities they have. A daily global ceiling bounds a bad day without depending on identifying anybody.

Each was verified blocking on its own, over HTTP, against a running server. A request refused by one control spends nothing from the others: four limits that all consumed on refusal would be one limit wearing four hats.

The email address is not one of the controls

It is deliberately uncapped. A cap on free scanning turns an evaluating agency away before they have seen what monitoring is for, and it is defeated with a second address anyway.

What the address buys is a record. A scan makes real requests to somebody else's server, and when that site's owner asks who sent an unfamiliar crawler at their checkout, the alternative to a record is a shrug. The address is checked for shape, against a curated disposable-provider list, and for whether its domain accepts mail at all. No confirmation email is sent — that is friction paid by every honest visitor to inconvenience somebody who will use a different address.

The attestation replaces the operator

A required checkbox, whose sentence is stored verbatim with the scan alongside the email, the IP and the time. Unticked means no scan, not a warning. Storing the words rather than a boolean matters: a stored true cannot tell anybody what was agreed to, and the wording will change.

Behaving like a crawler that expects to be blocked

The User-Agent now leads with the tool's name and a link to /crawler rather than with a Chrome string. Masquerading was defensible while every scan was one the operator had agreed to; on a public form it is not.

/crawler is written for the person whose site was scanned, not for a customer. Blocking instructions first — the robots token, the exact User-Agent, and an opt-out list read on every request so a domain added now takes effect on the next scan rather than the next restart. A domain on that list is refused for everybody, including its owner.

A defect the live test found

ARL_SELF_SERVE_TARGET_COOLDOWN_MIN=0 was silently replaced by the default of 60 — the config reader fell back on anything it could not parse. For an ordinary setting that is untidy; for a control bounding traffic at third parties, silently substituting the permissive default for a typo is the wrong direction to fail in, and nothing would have said so. Malformed limits now block activation and are named. Zero is honoured for the cooldown, because there it is a real choice, and warned about loudly at every boot.

Two paths removed

/scanning-restricted used to offer "run it yourself" and "email for credentials". The first pointed at a repository that does not exist — advice nobody could act on, which is worse than silence. The second was the operator standing in the middle of a queue of one. Both are gone, with ARL_REPOSITORY_URL deleted outright, and a test sweeps every page to keep them gone.

What an adversarial review found, and what is still open

Six independent reviewers attacked the implementation before it shipped. What they found and what was done:

Fixed.

  • POST /rescan applied none of the four controls. It re-ran any stored crawl with no email, no attestation, no lead record and no cooldown — so a self-serve visitor could keep the report id they were handed and re-scan that target indefinitely, defeating the one control that protects a site which never agreed to any of it. Found by two lenses independently. Both crawl-starting endpoints now call one gate, and a test counts the call sites against the gates.
  • A failed prerequisite stripped the controls instead of closing the endpoint. Requested-but-blocked left selfServeActive() false, so the gate was skipped and the request fell through to the ungated path. An operator who sets ARL_SELF_SERVE=1 has probably removed the proxy password, so a failed prerequisite would have produced an endpoint that was open and uncontrolled — the exact inverse of what a prerequisite is for. It now refuses every scan.
  • Any path beginning with / skipped the gate and switched off the private-address refusal. Only the four fixture directories match now.
  • The terms fetch followed redirects after a one-shot address check, so a terms link that redirected to a loopback or metadata address was fetched and up to 50KB of it stored. Each hop is validated before it is taken.
  • The opt-out list only applied while self-serve was on, which made /crawler's promise — "no visitor can scan it here at all, including one who owns it" — true only when the feature was switched on, and exempted the operator's own scans. It applies to every scan now.
  • /privacy and the scan form promised the report link would be emailed. Nothing sends mail to a lead. That is a capability claim for code that does not exist, and it slipped through because the build checks grep for social proof rather than for promises.
  • /crawler claimed every request is a GET and no form is submitted. The walk clicks a plan's call-to-action, which is often a submit button. Reworded to what is true: it submits forms the way a browsing customer does, and will not enter a card, sign up, or click a control that reads as completing a purchase. Two other claims on that page were also corrected — robots is checked before visiting a page rather than "on every request", and a report URL is random rather than "unguessable", which is a meaningful difference when the URL is the only thing protecting it.
  • peek() ignored the caller's clock while take() honoured it. Benign in production, where nobody passes a clock — but three tests began failing the moment real time crossed their hardcoded fixture date, which is how it was found. A fake clock that is only fake until Tuesday is worse than none.
  • A redirect landed wherever it liked, and the result was readable. This was the worst of them and it was not the rate limits. allowed() validated the URL the walk *chose*; page.goto() then followed HTTP redirects inside Chromium, which does its own DNS. A public A record redirecting to 127.0.0.1:3100 or to a cloud metadata address was fetched, screenshotted, stored, and served at /report/:id — where the person who asked for the scan could read it. That is a read primitive with a delivery mechanism, not blind request forgery. It also defeated two controls keyed on the submitted hostname: the opt-out list was asked about the attacker's domain and the cooldown stamped the attacker's bucket, so an opted-out site could be crawled and its own cooldown never touched. And the landed origin's robots.txt was never read, which CLAUDE.md says happens without exception.

Both navigations now check where they actually landed, before the first capture — a page that must not be measured must also not be photographed. A redirect that leaves the registrable domain refuses the scan outright rather than being re-keyed: by the time the browser has landed, the cooldown is stamped and the opt-out consulted, both for the wrong site. A same-site redirect is fine and common — http to https, www to apex — and has its address re-checked, because a sibling subdomain can resolve somewhere private.

Still open. Subresource loads are not gated, and the resolved address is not pinned between the check and the fetch, so a DNS record that answers differently on the second lookup wins. Both need validation at the request layer (Playwright's page.route) rather than at the call sites. Neither is a read primitive any more — nothing from an off-target page is captured or stored — but a blind request to an internal address is still reachable through a subresource, and that is enough to keep self-serve switched off until it is closed.

Not switched on

ARL_SELF_SERVE is unset on the deployed instance, and setting it today would change nothing: PUBLIC_URL is http://162.243.62.243, and the https prerequisite has no engineering workaround. A public form collecting email addresses and authorization records over plain http is not acceptable.

MIGRATION.md describes moving the scanner to its own droplet — not done, and the largest remaining risk while public traffic can spawn Chromium on a box that also serves other people's production sites.

Free against monitored, in a table (0.13.0)

Free against monitored, in a table (0.13.0)

Ten rows on /pricing, below the calculator. A real table with headers on both axes; at narrow widths it stacks into labelled blocks rather than scrolling sideways, using data-label written by the server so it works with no JavaScript.

Every row names the source files that make it true, and check-fabrication verifies they exist — the same no-vaporware rule the tier features are held to. A row describing something not built fails the build.

Three rows that could have been crosses are not, because a cross would have been false. Free gets the real checkout-health issue count, not zero information. Every report is already compared against the previous scan of the same URL, whoever ran it — what monitoring buys is that the previous scan exists without anyone running one, and that the change is emailed. And a free report stays at its link permanently.

The last of those is a correction to what was going to be written. The row was drafted as "week-over-week comparison: — / included", which is what a pricing page would normally say. diffAgainstPrevious() is called on every report render with no gate on it, so that cross would have been a lie about the free tier in the direction that flatters the paid one.

Free is not degraded to lengthen the paid column. Every legal observation is in every free report in full, and a test asserts the free and paid cells of that row are identical. The row about sites the scanner cannot reach says the same thing in both columns and is marked as saying it: a site behind bot protection is unreachable whether or not money changed hands, and weekly monitoring of it buys the same refusal once a week.

The paid column is emphasised with weight and a hairline. Not the accent — that marks the one primary action on a page — and no warm colour, which belongs to severity. A cell meaning "nothing" is an em dash plus a visually-hidden "not included", never colour or position alone.

The home page figure is gone

The ASCII drawing of the walk above the headline — three frames ending at the card field — was removed on request. Nothing depended on it and nothing replaced it; the headline is the opening statement now, which is what it was competing with. src/pages/hero.ts and its stylesheet block are deleted, and a test asserts it stays gone.

Graduated pricing, one visual system, a cool accent (0.12.0)

Graduated pricing, one visual system, a cool accent (0.12.0)

The pricing model changed from Volume to Graduated

Volume repriced every unit to the band the total landed in, which produced a curve nobody intended: the second site added $1.99 and the seventh added $3.99, because crossing a boundary discounted everything behind it. Under graduated tiers each unit bills at its own band's rate, so every site after the first costs about $10.99 and the marginal cost of one more site is simply that site's rate.

The two formulas read almost the same and disagree by tens of dollars — six sites is $65.94 under volume and $74.94 under graduated, and both look like a price. The totals are asserted against a written-out table at 1, 2, 3, 6, 7, 10 and 20, and a second assertion checks the property that makes the arithmetic trustworthy: adding one site always costs exactly what the previous quantity said it would.

The startup check got simpler because the mode changed. The boundary-total check belonged to volume, where a steep enough discount could genuinely make the bill fall as a site was added. Under graduated tiers that is impossible, and the two properties that imply it are checked instead: every rate is positive, and rates never rise with quantity.

The page now shows the marginal cost of the next site. "Your next site costs $9.99" tells somebody what a decision costs; an average only tells them what they are already paying. It is suppressed at the configured ceiling, where there is no next site to quote.

The old payment link is unset, deliberately. It was created against the volume price, which is archived, and a Stripe price is immutable. Left in place it would charge volume rates against a page quoting graduated ones — the same class of fault as a stale variable resurrecting old pricing, which this project already refuses to start over. Until a link for the graduated price is supplied, the Subscribe button is off and the page names the variable.

A number you can type instead of a dropdown

The quantity control is a number input with preset buttons for 1, 3, 5, 10 and the ceiling. A dropdown made every quantity a scroll and 13 a scroll to the thirteenth item.

Both controls write the same query parameter and both work with JavaScript off: the presets are ordinary links and the field has a submit button beside it. With the script running, a preset fills the field instead of navigating.

The script does no arithmetic. Every total is computed on the server and shipped as a precomputed table it indexes, so the graduated formula exists once — a test strips the comments and fails the build on a multiplication operator or a rate literal. What the script does own is input discipline, which cannot be done on the server: below the minimum clamps up, above the maximum clamps down, and anything that is not a whole number puts back the last value that was. Never NaN, never a negative, never an empty price where a number used to be.

No native control chrome anywhere. appearance: none on every input, textarea, select and button, with the number field's spinners removed separately because both engines draw those outside appearance. Checkboxes and radios are the stated exception and restore it explicitly: a custom checkbox has to reimplement the indeterminate state, the focus ring and the hit target, and every reimplementation that stops short of all three is worse than what it replaced.

One visual system instead of two

The technical look now applies to every page — /checks, /methodology, /limitations, the state pages and the reports included. Same palette, same spacing scale, same header, same footer, same stylesheet. Dark mode is global rather than scoped, and print forces the light palette back with every token restated, because a report is evidence that gets printed and dark ink on a dark ground is not a document.

One typeface difference, in one place. Inside a rendered report, the long-form evidence prose is set in a serif — that is the text somebody sits with for ten minutes rather than scans. Its headings, chrome, tables, chips and signal values are identical to the rest of the site. A test asserts the exception may only reach report prose and may only change font-family, font-size and line-height, so it cannot grow back into a second register.

The accent moved off red

The accent was an oxide red, then a coral — one hue away from the colour marking a high-severity finding. On a tool whose job is signalling how serious something is, a brand colour that resembles the danger colour makes the most important signal in a report read as decoration.

The accent is now a desaturated cyan, used for links and the one primary action on a page. Warm colour is reserved entirely for severity: red for high, amber for medium, muted grey-blue for informational, a distinct neutral for could-not-determine. A test parses the stylesheet, converts every hex value to a hue, and fails the build on any warm colour that is not a --sev-* token.

Severity carries three independent signals, not one: its own word, its own glyph (▲ ● ○ ?), and a fill that separates in greyscale. The high chip inverts between schemes — the only dark fill among pale ones in light mode, the only bright fill among dark ones in dark mode — because a dark red fill on a dark ground sits within 0.03 of the amber one in greyscale, which loses exactly the distinction that matters most. Both schemes say the same thing, and a test measures it.

The whole palette and the spacing scale are tokens in one :root block, with the rule written above them.

The marketing redesign (0.11.0)

The marketing pages were redesigned. Reports and /checks were not, and that is the point of the change rather than an omission.

Two visual registers instead of one

layout() takes a look. Pages that sell and explain — home, pricing, background, about, contact, FAQ, and the new paste-mode page — carry look-marketing: mono headings, one accent colour, hairlines instead of boxes, and section gaps large enough to feel like a mistake in the stylesheet. Reports, /checks, /methodology and /limitations keep look-document exactly as it was: serif, paper white, bordered panels.

They share the palette and a single spacing scale and diverge on type. An audit that looks like a landing page is less credible, not more, and the credibility of the report is the whole product.

Dark mode is scoped to the marketing look, through prefers-color-scheme. A report is evidence that gets printed and sent, and a document whose colours follow the reader's OS setting is a document whose PDF does not match what the reader saw. An acceptance test reads the stylesheet and fails the build if any dark-scheme rule is not qualified by the marketing class, so this cannot leak into a report by accident.

The home page is mostly gone

What is left, in order: a text figure, an eyebrow, a headline, two lines, the scan input, one line of disclaimer. Then a hairline, six short items headed What it looks for., three more headed What it can't see., and one link out. Nothing else.

The second list is the same size and weight as the first, deliberately. A scanner that lists its capabilities on the landing page and its limits three clicks away is asking to be trusted more than it has earned.

Nothing was deleted; four things moved. The paste form, the demo fixtures and the three things worth knowing before scanning are at /other-ways-to-scan, linked from the footer. The scanner version, the rule count, the oldest review date and the overdue count are at the top of /changelog, which is the page about how current things are. The three-step summary of the walk was a paraphrase of /how-it-works sections 1–3 and is now just a link to them. The limitations note became the What it can't see. list, which links the full page.

The hero is text

An ASCII and box-drawing figure of the walk: a plan with a price, the consent screen, and the card field where the scanner stops. Nothing is fetched to render it, it costs no JavaScript, and it survives the stylesheet failing to load. It is aria-hidden with the real <h1> beside it — read aloud it would recite punctuation for twenty seconds and teach nobody anything.

Two variants: 54 columns and 22. The wide one cannot fit a 320px screen at any size where its boxes still read as boxes, so the narrow screen gets a simpler figure rather than a scaled-to-illegible copy. A test measures every line of both and fails on a ragged one, and on any glyph outside ASCII and the box-drawing block — a character that renders double-width in somebody else's monospace font breaks the alignment that is the only thing holding the picture together.

check-fabrication got five more shapes

The reference this was designed against carries a testimonial marquee, a press-mentions row, a sponsors strip and a community showcase. This project has no users, no press and no sponsors. The existing rules catch fabricated sentences; these catch the CONTAINER, because a section is far easier to add than a sentence and an empty one invites being filled: press-logo-markup, sponsor-markup, showcase-markup, marquee-markup, star-rating-markup.

Also

The word budget before the scan input is unchanged at 60 and still enforced. The counter now excludes aria-hidden content, because the hero is characters rather than words and counting its box-drawing against a word budget measures the wrong thing.

Per-site pricing (0.10.0)

Billing moves from two flat plans to one subscription priced per site.

The price itself

  • One payment link and one tier table. ARL_STRIPE_LINK and ARL_PRICE_TIERS replace the two plan links and the two plan prices. The table is JSON in integer cents, shaped like Stripe's own tier objects, read in Stripe's Volume mode: the whole quantity is charged at the rate of the band it falls into, not blended across bands.
  • The four variables that configured the flat plans are retired, and the application refuses to start while any of them is set. A variable read by nothing is not harmless — it reads, to whoever opens the file next, as the price this instance charges, and the instance would be charging something else.
  • The tier table is validated at startup and a price inversion refuses to boot. Both kinds: a later band charging more per site than an earlier one, and a table where the total falls as a site is added, which is the version a customer notices. A missing table is deliberately not a failure — it renders the calculator switched off and names the variable, which is how this repository runs for anybody without a Stripe account.
  • /pricing is a calculator. A quantity select from one to the configured maximum, and the rate, the monthly total and the effective per-site price for whatever is chosen. The arithmetic runs once, on the server: every option carries its own precomputed total and the inline script does nothing but copy strings, so there is no second implementation to drift from the first. With JavaScript switched off the form's submit button reloads the page with ?sites=N and the server renders the same numbers.
  • The quantity does not travel to Stripe, and the page says so beside the button. Payment links accept no quantity parameter; Stripe's checkout opens at its own default and the customer sets the number again there. A calculator that implied otherwise would be the most expensive kind of wrong on that page.

Monitor records

  • tier became an integer siteLimit — the quantity on the Stripe subscription. Two tier names could not describe a subscription of twelve. Reading a file migrates in memory so no scheduler meets an undefined limit; npm run monitors -- migrate rewrites it on disk and prints every record it changed. The one existing record migrated from one-site to siteLimit: 1.
  • --sites N replaces --tier, and --tier is now an error rather than a silently ignored flag. The CLI still refuses a domain list longer than the limit. A list *shorter* than the quantity is allowed and reported: above about ten sites that is the ordinary state, because the checkout's domains field holds 255 characters.
  • set-limit --id … --sites N moves a limit, and refuses to move it below the domain count rather than silently dropping domains from the weekly run.

Onboarding above ten sites

  • /monitoring-requested — the page Stripe's payment link redirects to after checkout. It says nothing is scanned yet, and that a domain list too long for the 255-character field is completed by email before the first scan. A short list should not read as the whole of it.
  • /operator/subscriptions — which recorded payments have no monitor record behind them, which are limited below what was paid for, and which active monitors have no payment. Behind its own credentials (ARL_OPERATOR_USER / ARL_OPERATOR_PASSWORD) and answering 404 when they are unset, so a missing variable makes the page absent rather than open. It lists paying customers' email addresses; it is not going to depend on a proxy rule being spelled correctly, which is how POST /scan/ once ran with no credentials at all.
  • npm run monitors -- record-payment and reconcile. Nothing here reads Stripe: there is no API key in this project and no webhook endpoint, and both the page and the CLI say so where they display the records. What writing a payment down buys is that "paid for, and not being scanned" becomes a query instead of a memory.

A drift found on the way

/scans — the history page, which lists scan targets, and a scan target is somebody's checkout — is protected on the live host and was absent from the config generator in this repository, whose own acceptance test pinned the pattern without it. Running npm run nginx-install would have removed the protection. Both are fixed and the test now asserts the plural spelling.

How the two came apart is not recoverable from what is on disk: the live block carries a comment this generator never wrote, so either the generator regressed or the file was edited by hand. Either way the lesson is the same one — a live config and the thing that generates it are two copies, and only one of them was under test.

Not done

ARL_PRICE_TIERS is unset on the deployed instance. The supplied Stripe table gives 2–6 sites at $10.99 and 7 or more at $9.99; the band covering a single site was cut off before it arrived. Until it is known, /pricing shows the calculator switched off and the Subscribe button disabled — the honest state — rather than quoting a first-band rate nobody set.

Corrections (0.9.0)

Twenty real sites were scanned and the findings read one by one against the live pages. Most of them were wrong. This section records what was wrong and what changed.

The walk left the site it was asked to look at

Four of twenty walks ended on a domain nobody asked about — three on Google or Apple sign-in, one on an affiliate host — and every observation from those pages was reported as if it described the target. The walk now records the registrable domain of its entry URL and refuses to analyse a capture from anywhere else, stopping with a named failure mode (left-target-origin) that says which host it landed on. Subdomains of the target are still followed, because checkout. and shop. prefixes are how most real checkouts are built. The link pattern that chose where to click was also part of the cause: it treated a bare "join" as a pricing link, which is how an affiliate footer won over a pricing nav item. Link text now outranks the URL path.

A submit button is not consent

Any submit button counted as the consent moment, so a newsletter form, a login screen and a "Contact us" button were all read as the point where a customer agrees to be charged. Identification is now positive and needs two things on one screen: a recurring price, and a control whose accessible name reads as purchasing — a checkbox alone does not qualify and neither does a button alone. "Get started", "Learn more", "Sign up" and "Contact" are explicitly not purchase controls. Where nothing qualifies, consentStepFound is false and the three rules that only mean anything at that moment resolve to could-not-determine instead of firing. The card-field stop now also requires a field that is visible and interactable, because at least one platform preloads hidden payment iframes on pages that are not checkouts.

A headline described a page element that was not there

ARL-AFFIRMATIVE-CONSENT ended its headline "— just the terms-of-service box." It fired eighteen times out of eighteen eligible scans and that clause was false on every one; twelve of those pages contained no checkbox of any kind. The sentence contained no banned word and invented no person, so nothing in the build noticed for a week. The headline is now assembled from the counts the scan recorded, and zero checkboxes resolves to could-not-determine rather than to a high finding about a control that was never found.

npm run check-observation now fails the build on any unconditional finding text that names a page element without interpolating a measured value, and the acceptance suite renders every headline against a fixture with no form controls at all. The exact string that shipped is quoted in a test and asserted to fail.

Checkout health counted the reader's browser extensions

Twenty-four of fifty-four reported health issues were requests made by browser extensions, not by the page. Non-http(s) schemes are discarded, and what remains is classified first-party or third-party against the target's registrable domain. Only first-party failures are counted; third-party ones are listed separately and labelled as somebody else's asset. A robots.txt refusal is a note, not a broken document.

Two matchers and a contradiction

  • The price-mismatch check missed $17/shipment against $34.00 every 4 weeks — the highest-value observation in the system, and it silently did nothing. Per-unit, "every N weeks", "every other week", "per shipment" and "billed every N" now normalize.
  • The online-cancellation matcher missed plural and adjectival wording and never looked at the body of the terms document it had already fetched. It now does both, with a phone-context exclusion so "call to cancel" is not read as an online route, and with route prose held to a stricter pattern than a link label so "until cancelled in accordance with these terms" no longer counts.
  • One site was told both that a subscription flow was found and that none was. The flow answer now has a single source of truth and a test asserting the two readers of it cannot disagree.

New

  • npm run rule-stats — per-rule eligible and fired counts across stored reports, flagging any rule above 90% or below 1%. The eighteen-of-eighteen rule was visible in the data for a week with nothing to surface it.
  • CLAUDE.md — the working rules, including that anything touching the live internet uses GET only. A subagent researching payment pages attempted to create a checkout session on a third party's production Stripe account with a guessed tier id. It was blocked before it left the machine and nothing reached the target, but the instructions it had been given never forbade it.

Still outstanding

The twenty-site re-run against these fixes has not been done, so the numbers above describe the defects, not the improvement.

2026-08-05

scanner 0.8.0 · rule matrix 2026-08-04

One dated entry covers the day, as before: 0.8.0 is the pricing page, 0.7.0 the weekly monitoring work, 0.6.0 the checkout-health work, then the 0.5.0 flow-gate and density work, then 0.4.0.

The pricing page (0.8.0)

  • /pricing, linked from the header after Limitations — what monitoring cannot do is stated before what it costs. Three tiers: Free (every legal observation in full, checkout health as a count), One site at $29.99/month (full checkout health, weekly scan, change-only email, history and diffs), and Up to ten sites. Manual scans stay free at every tier, including for subscribers. The page links /limitations, says plainly that a site that blocks the crawler will keep blocking it weekly, and tells the reader to run a free scan first rather than buy monitoring for a site that cannot be scanned. The customer portal is linked directly for cancellation.
  • Payment links, the portal and the ten-site price come from config (src/config/billing.ts), never from source literals — check-fabrication now fails the build on a hardcoded Stripe link. A button whose config is unset renders disabled with an explicit "not yet available"; the ten-site price is set in Stripe and stays off here until it is copied into config.
  • check-fabrication now enforces no-vaporware pricing. Every feature a tier lists lives in src/pricing-features.mjs and names the source files implementing it; the check verifies those files exist. A feature that is not built must carry notYetAvailable, which the page is obliged to render as an explicit marker — so a pricing page listing an unbuilt feature without saying so cannot build. Also new: a shape rule for popularity badges, which need customers this tool does not have.

Weekly monitoring — what the paid tier is (0.7.0)

  • Monitor records in monitors.json (gitignored; customer emails and Stripe ids), managed by an operator CLI (npm run monitors) — no self-serve signup. Each record: customer email, Stripe id, domains, tier and site limit, the authorization confirmation captured at checkout with its timestamp, state scope, a weekly day slot, and the last report id per domain.
  • The scheduler (scripts/run-monitors.ts, cron daily, each monitor weekly on its slot — staggered rather than batched). One scan at a time is now enforced across processes by a file lock (src/lock.ts) that runCrawlScan and runPasteScan take, so a scheduled scan never overlaps one the web server is running. A run defers, logged, when available memory is under ARL_MONITOR_MIN_AVAILABLE_MB (default 300). Site limits are enforced per tier at run time. Scheduled scans respect robots.txt and the private-address refusal exactly as manual scans do. Every run logs to logs/monitor.log, including days with nothing due, so silence is distinguishable from a broken scheduler.
  • Change-only email. Each new report is diffed against the previous one for the domain; nothing is sent when nothing changed. Sent when a finding appears or disappears, a checkout-health issue appears, an undetermined item resolves either way, or matrix additions produced results for the domain. Short: the changes in plain language, a link to the report, and the customer portal link. Delivery reuses the cert-monitor relay credential file (/etc/cert-monitor.conf) — no second SMTP credential store. A one-line monthly note says monitoring is still running when nothing has changed.

Checkout health — a second category of check (0.6.0)

Checkout health — a second category of check (0.6.0)

  • Every walk now records whether the checkout was mechanically working that day: JavaScript errors and uncaught exceptions on each step, requests that answered 4xx/5xx or never completed, the HTTP status of every linked terms document the scan fetched, a primary call-to-action that was clicked but led nowhere, a price at the consent step that never appeared on the earlier steps, and a re-render of the consent step at 390px recording whether the consent and submit controls fit without horizontal scroll. All of it reuses the walk that already happens; the only new request is the one mobile re-render of a page the walk already visited.
  • Strictly separate from the legal observations. Its own report section after them, its own status words (issue observed / nothing observed / could not be determined), no statute references, no state names. The same three-valued logic: a walk that never reached a consent step reports mobile rendering as could-not-be-determined, never as passing.
  • The itemised detail is part of the paid monitoring plans. The signals are gathered on every scan regardless — gating is a display decision. Every report shows the real issue count, including zero; the detail unlocks on any scan of a domain an active monitor record covers, whoever ran the scan. Monitor records live in monitors.json (gitignored — it carries customer email addresses), read by src/monitors.ts.
  • New fixture broken: a checkout that throws on load, requests a script that 404s, and links a Terms page that does not exist. /checks#checkout-health and /methodology#checkout-health document every check with the same last-reviewed treatment as the rule summaries.

The flow gate (0.5.0)

  • A scan of a site with nothing to buy no longer produces findings. A real scan of a marketing site reported a high finding about its consent control while its own notes said, in the same view, that no consent moment existed. Absence signals are meaningless where the thing could not have been present.
  • New signal subscriptionFlowReached: true only when a step showed a price with a recurring billing interval, recurring-billing wording, plan-selection wording, a card field, or a consent control beside a price. A checkbox alone is not enough — the older heuristic counted one, which is how a newsletter form let findings through.
  • Rules declare requiresFlow individually and the evaluator is driven by the flag, so a new rule cannot skip the decision silently. Gated rules resolve to could-not-determine when the gate is closed — never to a finding — and the report opens with a notice above the summary: zero findings here is the walk not arriving, not the flow being clean.
  • Re-runs: the marketing-site scan went from 2 findings to 0 with six checks could-not-determine; the bad fixture still fires all five findings.

Undetermined entries speak plainly too

  • Each carries a headline phrased as the open question — “Couldn’t tell whether there’s a separate consent box for auto-renewal” — instead of the framework sentence, and a specific reason composed from why each missing signal was withheld: the visual pass did not run, no terms pages were fetched, the flow was never reached. The reason is what tells the reader whether a re-run could do better.

Evidence that points at something

  • Every region is recorded at match time, including the new trial region. An absence finding crops to the place the missing thing was expected — the consent moment for disclosure-at-consent — and the caption says so.
  • A signal whose match lives in fetched terms text quotes the matched sentence as the evidence. A finding with no on-page element at all shows no screenshot and says what was searched. The uncropped page-top fallback with its “region could not be located” note is gone: a full-page screenshot that does not point at anything is decoration wearing the clothes of proof.

Citations render once

  • The collapsed face of a finding carries a compact line of state names linking to the state pages. Full citations and official code links render exactly once, inside the disclosure. A nine-state finding was printing eighteen citation blocks.

The state selector collapses

  • Both forms default to “All US states in the matrix” with the specific-state list behind a disclosure. No script involved; a closed disclosure still submits its inputs.

Also released this day, as scanner 0.4.0

Ordering and plain language. No check was added or removed, no statute summary changed, and the matrix fingerprint is unmoved — it hashes rule ids and state codes, and neither changed. Nothing was deleted; several things moved.

Findings now lead with plain language

  • Every rule carries a headline: the problem in the words a person would use. "The fetched terms text describes cancelling by telephone" is accurate, and it made the reader translate it into "cancelling requires a phone call". The headline now says the second; the evidence sentence is kept and shown as supporting text, because it is the sentence that records what was measured.
  • A headline may be condition-specific, so a rule that fires for two different reasons is not headlined identically in both, and may carry an observed value — "The renewal text is 8px" rather than "below the threshold".
  • The line that is not crossed: describing what is ON THE PAGE in plain words is the goal; stating what the law concludes about it is not. check-language now rejects the conclusion phrasings that use no single banned word — telling a reader they are breaking rules, predicting they could be sued, instructing them that they must fix something — and the suite asserts each one fails.

Reports open with a summary

  • Domain, time, counts by severity, and one line per finding linking to its detail. The could-not-determine items are listed and counted separately, so a reader skimming cannot mistake one for a pass.
  • Evidence — the crop, the raw signal values, the evidence sentence, the statute and the code link — is collapsed behind Show evidence rather than removed. Five expanded findings read as a document; five collapsed ones read as a list of problems. <details> needs no JavaScript, so the headline reads with scripting off, and the print stylesheet forces every disclosure open so the archival copy is complete.

The home page leads with the tool

  • Above the fold: one sentence, the scan input, and one line saying this produces observations rather than legal conclusions. 28 words before the input.
  • The patchwork explanation — the vacated 2024 Negative Option Rule, the February 2026 restoration, the state-by-state consequence — moved whole to /background, linked from the home page, the FAQ and the footer.
  • The header is three items: Scan, What we check, Limitations. Limitations stays in the header deliberately: it is the page that says what a clean scan does not mean. Everything else moved to a grouped footer and is still reachable.

Fixed along the way

  • check-fabrication read a scan date as a star rating. A report scanned on the fifth of a month renders a date whose first two components match the pattern for a score out of five, so the build failed on the day of the month rather than on anything anyone had written. The pattern now refuses a match when another date component follows.
  • make-sample was silently producing an empty sample: the private-address guard added in 0.3.0 refuses loopback targets, and the generator scans its own fixture server on loopback. It now opts out explicitly, as the app already does for its own demo buttons.

Also released this day, as scanner 0.3.0

The work below was versioned 0.3.0 during the same day and is folded into this entry because the changelog carries one entry per date. It was never deployed and produced no report of its own — the running instance was on 0.2.0 throughout — so nothing is left pointing at a version this file does not list.

Security and correctness. No check was added or removed and no statute summary changed, so the matrix version does not move. The scanner version does, because one signal is now measured differently and reports either side of this entry are not comparable on it.

Scanner

  • disclosureFontSize now measures the SMALLEST matching element, not the first in document order. The check exists to catch fine print, and taking the first match meant a page that states the renewal terms plainly near the offer and restates them in 8px grey at the foot was scored on the prominent one — precisely the layout the check is for, measured on the wrong element. This was documented as a known failure in an earlier entry rather than fixed; it is now fixed.
  • The report names the element the measurement came from, how many elements matched, and the largest among them. A bare number told a reader that something on the page was 8px, not which words to go and look at.
  • The cost of measuring the minimum is disclosed rather than hidden: an incidental small mention — a footer badge, a cookie banner — is now what gets measured on a page whose real disclosure is well set. The named element is how a reader catches that, and /methodology says so.
  • Correction to an earlier entry: /checks and /methodology previously described this signal as measuring the smallest element while the code took the first. The description was corrected to match the code at the time. The behaviour, not the description, was the thing that was wrong.

Refusing private scan targets

  • The crawler now refuses any target that resolves to a loopback, link-local (including 169.254.169.254), RFC1918, carrier-grade-NAT or unique-local address, and reports it as the new private-address-refused failure mode rather than as a robots.txt rule.
  • The check runs after DNS resolution, not on the URL string. An ordinary hostname is free to point at 127.0.0.1 or at a cloud metadata address, and only resolving it first will show that. If a name answers with several addresses and any one of them is private, the whole name is refused.
  • This matters because this host also serves other sites. Without it, anyone able to start a scan could aim the crawler at the machine it runs on and have it fetch, screenshot and store pages from a neighbouring site over loopback, where no proxy rule applies.
  • The demo fixtures still scan. The exemption is granted only to a URL this server built for its own loopback port, and it is persisted on the scan record rather than recomputed later — a refused scan still saves a partial report, and recomputing would have handed that report's re-scan the exemption its first attempt was denied.
  • This is a prerequisite for ever opening scanning to the public, not a replacement for the password. Both are wanted, and the password stays.

Visual pass

  • Token usage and the model name are now recorded per request, including on retries. The cost of a scan could not previously be answered from a stored report at all, which made "is a free tier viable" unanswerable except by guessing from a rate card.
  • Whether the model's JSON parsed on the first attempt is recorded, because "it works" and "it works first time" are different claims.
  • The visual pass has still never executed. ANTHROPIC_API_KEY is empty in this deployment's .env, so every report continues to mark the visual checks undetermined, and the instrumentation above is untested against a live response.

2026-08-04

scanner 0.2.0 · rule matrix 2026-08-04

Credibility and deployment. No check changed and no statute summary changed, so the matrix version moves only because provenance dates were added to every rule.

Rule matrix

  • Every check now records lastReviewed — the date a human last read its requirement wording against its sources — and sources, the official code pages it was drafted from. Both are shown on /checks and on every state page the check relates to.
  • A check whose summary has not been reviewed in 90 days is now marked overdue on the pages that render it. Ninety days is a figure this project chose; no statute states a review interval.
  • All nine checks carry 2026-08-03, the date the matrix was assembled and its requirement wording drafted. No check has been re-read against primary text since, and none is claimed to have been.

Scanner

  • Reports now record and display the scanner version, the rule matrix version and a structural fingerprint of the matrix. Reports written before this change show *not recorded* for all three rather than being given today's values.
  • Scans run through a queue with a live progress view showing real steps — locating the pricing page, walking forward, capturing, computing signals, evaluating rules — instead of a blank wait.
  • Four named failure modes (bot protection, no subscription product found, site unreachable, robots.txt disallows) each render their own page naming paste mode as the fallback, rather than a generic error.
  • Custom 404 and 500 pages, a per-IP rate limit on the scan endpoints, and a generated sitemap.xml.

Site

  • /about and /contact: who maintains this, what their background is, and how to send a correction to the rule data. Operator identity is configuration, and any field not filled in renders as a visible placeholder rather than as something plausible.
  • /methodology: what every signal measures, how it is computed, and its known failure modes — including that paste mode cannot evaluate visual prominence, because the stylesheets that determine prominence are never fetched.
  • /changelog: this file.
  • A typographic masthead replaces the plain text header, and the same letterforms generate the favicon and the share image.
  • npm run check-fabrication fails the build on testimonial-shaped copy, invented customer counts, trust badges and fake activity. There are no users and no customers, and nothing on the site may imply otherwise.
  • Optional HTTP basic auth on the three endpoints that start a scan, and only those, configured through ARL_SCAN_AUTH_FILE. Every read route stays open, so the site remains publicly verifiable. Where it is switched on, the home page says so above the form and /scanning-restricted explains what is still open — a scan form that answers with an unexplained password dialog would be the same kind of small dishonesty this tool spends its effort avoiding.
  • Corrected within the same day, and recorded rather than tidied away: the first version of that auth was bypassable. nginx protected the pattern ^/(scan|paste|rescan)$, but Express matches loosely by default and also answered /scan/ and /SCAN, neither of which the pattern covered — so POST /scan/ started a scan with no credentials at all. Fixed on both sides: the proxy pattern is now case-insensitive with an optional trailing slash, and the application refuses every spelling but the canonical one, so neither side alone is what keeps the endpoint shut. The acceptance suite asserts both.
  • An adversarial audit of that auth change then found five more, all recorded here rather than quietly fixed. Two were in the deployment guide and both were silent: npm run nginx-config | sudo tee ... produced a config nginx cannot parse, because npm writes its run banner to stdout — and a broken file in sites-enabled makes certbot abort before it does anything, which would have killed unattended renewal for the three unrelated certificates on the same host. The other told the operator to copy the generated config over the live one, which after certbot has run discards the TLS block while nginx -t still passes, taking a site off HTTPS with no error anywhere. Both are replaced by npm run nginx-install, which parses the config in a throwaway nginx before it reaches sites-enabled and refuses outright to overwrite a certbot-managed file.
  • The claim that error_page 401 discards the WWW-Authenticate header, and the directive added to compensate, were both wrong. Measured on nginx 1.24 with two isolated instances differing only in that directive, the challenge survives the internal redirect either way. The directive is gone and the documentation now says what was measured, with the verification step that checks it. A config comment asserting a mechanism that does not exist is how the next person reasons their way into a real mistake.
  • The permanent /.well-known/acme-challenge/ location added in the same change is also gone. It was guarding against the scan password catching the challenge path — which it never could, being scoped to three exact endpoints — while risking the opposite, since certbot's nginx authenticator inserts its own temporary location and a permanent ^~ block competes with it.
  • The application's own rate limiter read the left-most X-Forwarded-For entry, which is a value the client chooses, so anyone could send X-Forwarded-For: 127.0.0.1 and claim the loopback exemption meant for the demo fixture buttons. It now uses req.ip, which Express computes from the right-hand end according to the configured proxy hop count.
  • Smaller corrections from the same audit: the re-scan button on every report was the one scan-starting form left without the credentials notice; the 401 page offered "run it yourself" without a source location to get it from, and now says so when none is configured; the auth realm carried a UTF-8 em dash into a header that browsers render as raw octets in the password dialog; and the documented verification commands scanned example.com, which this tool's own terms ask people not to do — they use the built-in fixture instead.
  • Also corrected: any nginx location that defines an add_header loses every inherited one, so /evidence/ — the path serving screenshots of somebody's checkout — had been served without nosniff or X-Frame-Options since the reverse proxy was introduced earlier today. The headers are now restated in every location that sets one, and the suite checks that they are.

2026-08-03

scanner 0.1.0 · rule matrix 2026-08-03

The product surface, and the expansion of the matrix from a handful of states to a researched set.

Rule matrix

  • Expanded to 9 checks across 18 jurisdictions, replacing the initial single-state-per-rule shape. A check now relates to many states at once: the scanner observes one thing, and any number of statutes may bear on it.
  • Jurisdictions are grouped by what they actually reach. General consumer frameworks that reach an ordinary online checkout (CA, CO, CT, IL, MA, ME, MN, NY, VA) carry the disclosure and cancellation checks. Frameworks gated on a contract-term threshold (DC, DE, HI, ND, VT) carry only the long-contract renewal-notice check, because a month-to-month subscription can sit outside them entirely.
  • Jurisdictions that are tracked but carry no check are shown rather than dropped: pending bills (MI, PA), a state with nothing in force and nothing pending (TX), a sector-specific provision that does not reach an online subscription (UT), and two whose status could not be settled (GA, MD).
  • Every legal assertion on the site became a LegalClaim carrying its own source and confidence rating, and LEGAL-CLAIMS-TO-VERIFY.md is generated from that registry so the verification document cannot fall behind the copy.
  • Corrections made while researching: Minnesota was cited to §325G.30, a plain-language contract provision on an unrelated subject, and is now cited to §325G.57. Virginia was linked to chapter 17.2, agricultural equipment warranties, and is now linked to chapter 17.8. Maryland would have been cited to §14-1328, a limitations-period section, and is now cited to §14-1329.

Scanner

  • State scoping: a scan records which states the merchant said it has subscribers in. Scoping is applied when a report is rendered rather than when it is stored, so a saved report can be re-scoped without re-scanning and nothing is dropped on write.
  • Scan-to-scan comparison on /scans, three-way rather than two-way: a finding that becomes undetermined is not the same as one no longer observed. Checks added to the matrix since an earlier scan are counted separately and never reported as a change to the site.
  • Reports record which rule ids the matrix held when they ran, so a later comparison can tell "we added a check" apart from "the site changed".
  • A committed sample report generated from the bad demo fixture, so /sample is reachable without running anything.

Site

  • /checks and every /state/:code page are generated from the matrix, with no hand-maintained copy on either. Adding a rule changes both with no further edit.
  • /how-it-works, /limitations, /faq, /disclaimer, /privacy, /terms, /scans, and a page per finding carrying its evidence crop and raw signals.
  • A print stylesheet that lays a report out for letter paper and keeps the disclaimer on page one.

2026-08-02

scanner 0.1.0 · rule matrix 2026-08-02

Initial version.

Rule matrix

  • The first matrix: disclosure at the consent moment, disclosure presentation, affirmative consent, trial conversion, online cancellation route, telephone-only cancellation, and three checks that no outside scan can reach and that are therefore reported as manual verification in every report.
  • Three-valued rule evaluation from the start. A rule that refers to a signal the scan never observed produces no finding at any level; it is listed as *could not be determined* with the missing signal names shown. Absent evidence is not evidence, in either direction.
  • Severity fixed by the rule rather than by the scan, with published ceilings: a finding resting on the visual pass is capped at medium, a finding resting on something not appearing is capped at medium, and severity never varies by state.

Scanner

  • Playwright walk: discovery of a pricing page, up to four steps forward, stopping at the card field. It never clicks a control that reads as completing a purchase, identifies itself in its user agent, respects robots.txt, and runs one scan at a time.
  • Deterministic signal pass over captured text and geometry, and an optional visual pass that describes each screenshot and is prompted not to judge.
  • Paste mode: markup rendered locally with every network request blocked, so nothing at all is requested from the target site.
  • npm run check-language: the build fails if anything this project authors states a legal conclusion about a business rather than an observation. The words it refuses are listed in scripts/check-language.mjs.

Every report records the scanner version and the rule-matrix version it ran under, so a report read months from now can be matched against the entry above that describes what produced it. Reports written before version recording existed show not recorded rather than being given a version after the fact. How each signal is computed · Every check, with the date its summary was last reviewed.