Shipmind Labs

An accessibility statement your audit trail can support

· 9 min read

An accessibility statement is a public compliance claim, and in most organisations it is produced the way a summary gets produced: someone reads the last audit report, forms an impression, and picks one of the three statuses the European model statement allows. The evidence and the claim are joined by a person's memory of a spreadsheet, which is precisely the joint that fails when a regulator asks what was checked, by whom, when, and on which build.

We build compliance tooling as a large part of our work — verification and moderation screens for compliance teams, e-signature and document workflows, decision trails that have to survive being questioned later. The shape of the problem is always the same: an artefact that asserts something must be derivable from a record, or it is only a sentence. Accessibility is a good place to show the mechanics, because the distance between what tools measure and what the document claims is unusually wide. Automated tooling settles roughly a quarter to a third of WCAG. The rest is judgement — whether alt text describes the picture, whether the focus order makes sense, whether an error message tells anyone what to do. So the statement is mostly a claim about human work, and human work leaves no trace unless you build one.

We put ours into a small TypeScript library, a11ytrail (https://github.com/shipmindlabs/a11ytrail). It runs no tests: axe-core, Playwright and a person with a screen reader do that. It consumes what they produce, keeps the record, and derives what may be claimed from it.

The unit of evidence is a check, not a criterion#

The first modelling decision is what a row means. The intuitive answer — one row per success criterion, holding pass or fail — is wrong, and it is wrong in a way that costs you the audit. A criterion is not checked in the abstract; it is checked on something, by someone, at a moment, against a build. Drop any of those and the row stops supporting the claim you want to make with it.

typescript
import { Evidence } from "a11ytrail";

const evidence = new Evidence()
  .add({
    criterion: "2.4.7",
    outcome: "passed",
    method: "manual",
    checkedAt: "2026-07-14",
    checkedBy: "audit team",
    scope: "home",
    build: "2026.07.1",
  })
  .add({
    criterion: "2.4.7",
    outcome: "failed",
    method: "assistive-technology",
    checkedAt: "2026-08-05",
    checkedBy: "audit team",
    scope: "checkout",
    note: "focus ring is removed on the payment step",
  });

evidence.latestPerScope("2.4.7");
// one check per scope: home passed, checkout failed

Two properties follow from that shape, and both matter more than they look.

The collection is append-only. A recorded check is copied and frozen, and the list handed back is a copy. Correcting a check means recording a later one — which is also exactly what re-testing after a fix looks like, so the two operations need no separate code path. The earlier result stays in the trail, because an audit asks what you knew and when, not what you believe now.

And later supersedes earlier per scope, never across scopes. That is what latestPerScope encodes. A pass on the home page does not speak for checkout; if it did, the cheapest way to become compliant would be to test the simplest page twice. We have shipped enough moderation and review tooling to know that the aggregate is where the lie enters — one green number over rows that were never comparable.

Anonymous evidence is not evidence#

Validation on write is where most of the discipline lives, because the cost of a bad row is paid months later by someone who cannot tell it is bad. A check that names no one, names no scope, or carries an unreadable date is rejected at add. So is an automated result that does not name the tool:

typescript
evidence.add({
  criterion: "1.4.3",
  outcome: "passed",
  method: "automated",
  checkedAt: "2026-08-05",
  checkedBy: "ci",
  scope: "checkout",
});
// InvalidCheck: automated check for 1.4.3 does not name the tool:
// a result nobody can reproduce is not evidence

The outcome enum is the other half of it. Alongside passed and failed there is not-applicable — the criterion cannot apply here, there is no audio, no video, no timing — and inconclusive: looked at, could not decide. inconclusive never counts as a pass. Teams resist that field at first, because it feels like recording your own indecision. It is the opposite: it is the only honest way to distinguish "we examined this and it is genuinely ambiguous" from "nobody has opened that page", and those two states demand completely different work.

"We do not know" has no representation in the output format#

Here is the part that decides whether the whole exercise is worth anything.

Internally, a claim can come back incomplete: WCAG's conformance requirement gives no partial credit, so a claim at a level means every criterion at that level is satisfied, and one criterion nobody has looked at makes the claim incomplete. Externally, the European model statement admits three values — fully compliant, partially compliant, non-compliant. There is no fourth for "the assessment is not finished."

The entire temptation of compliance reporting sits in that mismatch. Unknown looks close to partial. Rounding it down feels conservative, even responsible. It is neither: it publishes a claim about criteria nobody examined, in a document whose only value is that it is checkable.

typescript
import { assess, statement } from "a11ytrail";

const claim = assess(evidence, { level: "AA" });
claim.status; // "incomplete" — 1 of 55 criteria have no conclusive evidence

statement(claim, organisation, new Date());
// throws: the evidence does not support any compliance status yet:
// criteria remain unevaluated. Finish the assessment, or publish a
// statement that says the assessment is under way.

The generator refuses. Not a warning, not a nullable status field that every caller will coalesce to something publishable — a thrown error, because the caller's job at that point is to go and check the thing, and any softer signal gets handled once and then handled everywhere by copy-paste.

We apply the same rule in payment and verification work: a derived status that has a state the output format cannot express must fail loudly at the boundary rather than pick the nearest legal value. The nearest legal value is where audits die.

A gap is not a defect#

The complement of that rule is just as important and gets skipped more often. A criterion that passes on the home page and was never checked on checkout is not a failure. Report it as one and you send an engineer to fix code that may be perfectly fine, while the real answer — nobody looked at that page — never reaches the person who could schedule the test.

So coverage gaps travel separately from failures, and so do two other qualifiers that a flat pass/fail column destroys. Evidence ages: every check carries its date, and a page audited before four redesigns supports nothing, so staleness surfaces in the claim's caveats and in the published statement rather than being assumed away. And a pass recorded by a tool alone is flagged — on a criterion no tool can settle, such as 1.1.1 Non-text Content, it is called out separately. That is not weak evidence. It is absent evidence wearing a green tick, and it is the single most common way a compliance dashboard becomes fiction.

The parts a record of tests cannot answer#

Some of the statement does not follow from evidence at all. Whether an exemption is a disproportionate burden. What alternative a user is offered for content that fails. What a failing criterion actually means for the person sitting in front of it. The date the statement was last reviewed.

Those come back as pending and are marked as such in the rendered Markdown, rather than filled with something reasonable:

typescript
const published = statement(claim, organisation, new Date());

if (published.pending.length > 0) {
  // the decisions no record of tests can settle, listed rather than guessed
}

console.log(toMarkdown(published));

A plausible sentence in one of those slots is worse than an empty one, because it reads as answered. Nobody re-opens a paragraph that already sounds finished. This is the same reason our review gate rejects a comment that restates the code: it consumes the attention that a real question would have received.

What it costs to run#

Less than the spreadsheet it replaces, in machine terms — no runtime dependencies, and Node 22.18 or newer runs the TypeScript sources directly. The real cost is behavioural: someone has to record the check at the moment it happens, with the scope and the build named, and accept that the generator will refuse to publish until the record is complete. That refusal is the product. Everything else is formatting.

The scope is deliberately bounded to WCAG 2.2 levels A and AA — 55 success criteria. Level AAA is absent on purpose: nobody claims it for a whole product, and offering the option would invite a claim nobody can keep. The library builds the European model statement out of your own evidence; whether that satisfies a particular national implementation is a question for someone qualified to answer it.

What generalises beyond accessibility is the ordering. Most teams write the claim and then look for evidence to support it. Invert that, make the record the input and the document a projection of it, and the interesting engineering turns out to be everything the generator declines to say.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com