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 addrwith 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 /rescanapplied 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 setsARL_SELF_SERVE=1has 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. /privacyand 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./crawlerclaimed 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 whiletake()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 to127.0.0.1:3100or 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'srobots.txtwas never read, whichCLAUDE.mdsays 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_LINKandARL_PRICE_TIERSreplace 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.
/pricingis 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=Nand 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
tierbecame an integersiteLimit— 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 -- migraterewrites it on disk and prints every record it changed. The one existing record migrated fromone-sitetositeLimit: 1.--sites Nreplaces--tier, and--tieris 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 Nmoves 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 howPOST /scan/once ran with no credentials at all.npm run monitors -- record-paymentandreconcile. 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/shipmentagainst$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.