Skip to content

The accessibility harness, and what the P12 tokens told it

Status: recorded observations from milestone M04, reference profile, 2026-09-28; the D100 HISS lint from milestone M16, 2026-09-29

Milestone M04 builds the first accessibility gate: the P12 Concordia token file, one Svelte 5 component that consumes it, and a Playwright and axe-core suite that scans the component headless in a container with its network disabled. To run it on a machine with podman or docker:

make a11y-fetch   # the one networked step: image, Node, pnpm, offline store
make verify-a11y  # the gate alone; make verify-all runs it too

Without an engine, the image or the cache, or with an offline store that make a11y-fetch filled for another pnpm-lock.yaml, the gate prints SKIP: <reason>; the accessibility gate did not run. and exits 0, so an exit 0 is evidence only when the case lines are above it. CI runs make a11y-fetch before make verify-all, so the gate runs there instead of skipping (.github/workflows/ci.yml, D88).

M04 is done. Every exit criterion and both epics rest on the run recorded below and on tools/test_a11y.py, which runs inside make verify-all on every platform of the matrix. The epic E04-2 requirements REQ-P12-01, REQ-P12-02 and REQ-P12-03 are covered at the CSS level only, in browser emulation; their portal, AT-SPI2 and UKI halves are not M04's (D89), and no milestone's criteria carry them yet.

A pass covers one component, not the desktop. The gate scans the P12 token component. It closes no accessibility gate for the P05 shell or for an image, it proves nothing about the desktop portal, AT-SPI2 or UKI compilation, and it is not release evidence. P12 stays a proposal in planning/components.json (D86).

The P05 shell will not run through this gate. Since D101 (ADR-0004, 2026-09-29) the shell is native Rust on gpui and has no DOM for axe-core to scan. Its accessibility check is an AccessKit tree check: M16 runs it on the exported accesskit::TreeUpdate inside make verify-all, and M28 over AT-SPI against the running shell. This gate is unchanged and keeps covering the P12 token component, its CSS and its one Svelte 5 component.

What is tracked, and what is not

Path Role
ui/concordia-tokens/concordia-tokens.css the token file: every colour, stroke, focus-ring and motion value is a custom property on :root (D76)
ui/concordia-tokens/src/ConcordiaPanel.svelte the one Svelte 5 component; its style block uses tokens only
ui/concordia-tokens/package.json, pnpm-lock.yaml, pnpm-workspace.yaml exact versions, packageManager, exact engines, engineStrict
ui/concordia-tokens/toolchain.pin.json the image by digest, its browser revision, and the Node release by sha256
ui/concordia-tokens/tests/a11y.spec.js the eleven tests
ui/concordia-tokens/tests/probe.js the in-page measurement axe-core cannot make
ui/concordia-tokens/tests/en301549-clauses.json WCAG criterion to EN 301 549 V4.1.1 and V3.2.1 clause (D81)
ui/concordia-tokens/tests/fixtures/stub-heads-up-theme.css the D76 stub, never part of the build
ui/concordia-tokens/eslint.config.js the D100 HISS lint's flat configuration (M16)
ui/concordia-tokens/hiss-lint/no-self-recursion.js the lint's local rule: a function calling itself by name (HISS-01)
ui/concordia-tokens/hiss-lint/plants/ one planted violation per rule family, and the file on every limit
tools/verify_a11y.py the gate: make verify-a11y, make a11y-fetch, and a step of make verify-all
tools/test_a11y.py the half that needs no engine, inside make verify-all

Not tracked: the image, the Node tarball, the pnpm binary, the offline pnpm store and every run's logs. They live under AEGIS_A11Y_DIR, default ${XDG_CACHE_HOME:-$HOME/.cache}/aegis-a11y. A run keeps its step logs and the suite's JSON reports under runs/ and deletes the unpacked toolchain, the installed packages and the build; the newest eight runs are kept. The gate writes nothing into the repository, which the container sees read-only.

make a11y-fetch records the sha256 of the pnpm-lock.yaml it filled the offline store from, in offline/pnpm-lock.sha256 beside the store, and only after pnpm fetch succeeded. The gate compares it with the checkout's lockfile: a store filled before a lockfile bump lacks what the bump added, so the gate skips and names make a11y-fetch as the cure, as it does when a bump makes the image or an archive absent. Only a cached archive that no longer hashes to its pin is a failure.

The pins, and the digest a truncated one cannot be

Input Pin Where
Playwright image mcr.microsoft.com/playwright:v1.64.1-noble@sha256:7852dc5a7d72f5fe458a597d690f6c42fbc8e9e26ddfa95f8e6ef552558d9bbf; linux/amd64 manifest sha256:bdfda2a8fdf48707c5e50a49bd3e06d8d0c759153dc8ee057d8a81c3450bb3e9 toolchain.pin.json
Browser chromium-headless-shell revision 1248, Chromium 156.0.8078.4, as bundled by @playwright/test 1.64.1 and baked into the image toolchain.pin.json, read back from playwright-core/browsers.json
Node.js 26.12.0, node-v26.12.0-linux-x64.tar.xz sha256 fd47865ecd6391ebb431fc815e01b9135bf83ded5cf373247e9331e196b7aded toolchain.pin.json, engines.node
pnpm 12.14.0; @pnpm/exe.linux-x64 integrity sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ== packageManager, engines.pnpm, pnpm-lock.yaml
npm packages exact versions, each with its integrity package.json, pnpm-lock.yaml

The image digest was resolved on 2026-10-16 with skopeo inspect docker://mcr.microsoft.com/playwright:v1.64.1-noble, and the engine is asked for the image by that digest, never by tag: make a11y-fetch pulls mcr.microsoft.com/playwright@sha256:7852dc5a…, and the gate does not run unless the engine lists that exact digest among the image's repo digests. REQ-P12-04's source (export-020, section 7.3 of its P12 report) names its validation container as ghcr.io/lusoris/concordia-validate@sha256:8f47c3...: six hex digits and an ellipsis, of an image M04 does not adopt. A truncated digest names no single manifest, so it is recorded as non-pinnable (E04-2): the case a11y/truncated-digest-refused accepts the full 64-digit digest of the pinned image, refuses it cut to 63 digits and to 12, and refuses the source's own digest.

The Node tarball's sha256 is the value in the release's SHASUMS256.txt, whose signature verified on 2026-09-28 (26.10.0) and again on 2026-10-13 (26.11.1, both the detached SHASUMS256.txt.sig and the clearsigned SHASUMS256.txt.asc) against the Node.js release key 5BE8A3F6C8A5C01D106C0AD820B1A390B168D356 from nodejs/release-keys, and on 2026-10-16 (26.12.0, both files) against the release key C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C from the same list. The image ships its own Node, 24.20.0; the gate does not use it. Node 26.12.0 is mounted read-only at /opt/node and put first on PATH, and the exact engines pin with engineStrict turns any other Node into a refusal (a11y/image-node-refused). pnpm 12 is a native binary, so it needs no Node to run; its integrity is the one pnpm itself records in the first document of pnpm-lock.yaml.

The reference profile's own browser cache, ~/.cache/ms-playwright, held chromium-1228 on 2026-09-13 and chromium-1243 on 2026-09-28. Praetor's figure engine writes it, not an Aegis admission, and the gate never reads it: the browser the suite launches is the one inside the pinned image (D90).

How one run works

Every step is one container: run --rm --pull=never --read-only --tmpfs /tmp --shm-size=1g --network=none, the repository bind-mounted read-only at /repo, a per-run scratch directory at /work, Node and pnpm read-only, and the offline store and pnpm's metadata cache read-only at /offline. The first step copies the package out of /repo into /work/pkg, so nothing the suite writes reaches the checkout. Only make a11y-fetch runs a container with a network, for pnpm fetch, and with /offline writable.

The engine is rootless podman when it is on PATH, else docker. Rootless podman needs no daemon and no group, and what the container writes belongs to the user who ran the gate; docker's daemon runs as root here, so the gate passes --user with the caller's uid and gid (host.uid(), host.gid()). AEGIS_A11Y_ENGINE=docker forces the fallback. Both were run on the reference profile: podman 6.1.2 (run r20260928T215941-925c) and docker 29.8.1 (run r20260928T220230-fb99), with the same thirteen case lines. The containers run without --init: podman needs a separately packaged init binary for it, which Ubuntu 24.04's podman only recommends, and none is needed because Playwright starts vite preview itself and ends it by process group. A step that runs past its deadline has its container removed by name, because ending the engine client does not end the container.

The cases, and what each proves

Case Proves Criterion or epic
a11y/pin the pin, package.json and the lockfile name one image, one Node, one pnpm 1, 4
a11y/truncated-digest-refused 64 hex digits pin; 63, 12 and the source's six-digit form are refused E04-2, REQ-P12-04
a11y/image-present the engine stores the pinned digest 2, 5
a11y/lockfile-installs pnpm install --offline --frozen-lockfile --frozen-store on the pinned Node and pnpm E04-1 positive
a11y/hiss-lint every JavaScript and Svelte file of the package lints clean with zero warnings M16 D100 positive
a11y/hiss-lint-<family>-refused each of seven planted violations fails with exactly its findings M16 D100 negative
a11y/hiss-lint-at-the-limits a 60-line, complexity-10 and 50-statement function each lint clean M16 D100 boundary
a11y/lockfile-mismatch-refused a package.json loosened to ^5.57.2 is refused with ERR_PNPM_OUTDATED_LOCKFILE E04-1 negative
a11y/image-node-refused the image's Node 24.20.0 is refused with ERR_PNPM_UNSUPPORTED_ENGINE 4, 6
a11y/toolchain-readback every version read back inside the container; Chromium 156.0.8078.4 launches 1, 5
a11y/browser-revision-absent-refused without revision 1248 the launch fails, offline, and nothing is downloaded 5
a11y/build the static page builds cheapest exit
a11y/suite eleven tests, the D81 report, the measured checks 2, 7, 8, E04-2
a11y/planted-violation-fails one planted image-alt violation fails the run E04-1 boundary, 2
a11y/stripped-outline-fails a stripped focus outline fails the run 2, E04-2 negative
a11y/d76-stub-theme-fails the D76 stub fails the existing boundary test 8, REQ-P12-10 negative

The suite has no retries and forbids .only; tools/test_a11y.py fails when the spec, its harness or the probe gains a skip, a fixme, an expected failure, a retry setting, a rule exclusion or an impact filter, and each spelling has a planted negative there. The gate does not rely on that list alone: it counts a test as passed only when Playwright's JSON report says the test was expected to pass, its outcome was expected and its last result passed. Playwright's own ok flag is also true for an expected failure, a skip and a flaky retry, so a test.fail() added to a test that then fails would otherwise read as a pass. The gate also reads the measured reports itself (the tab stops, the stripped outline, both boundaries, the D76 stub, 200% text and the two emulations) and fails when one records another value. The planted runs select one test with --grep, and each case requires that test, and only that test, to fail with its own reason in the message: image-alt, indicator 0px is below the D16 floor and indicator 1px is below the D16 floor.

The recorded run

From run r20260928T215941-925c on podman 6.1.2, 20 seconds, lines wrapped at 80 columns and long digests shortened:

PASS a11y/truncated-digest-refused
     full sha256:eff16c30e6f3...a4a27: accepted
     63 digits sha256:eff16c30e6f3...a4a2: refused
     12 digits sha256:eff16c30e6f3: refused
     REQ-P12-04's source (export-020)
       ghcr.io/lusoris/concordia-validate@sha256:8f47c3...: refused
PASS a11y/lockfile-installs
     pnpm install --offline --frozen-lockfile --frozen-store: exit 0
PASS a11y/lockfile-mismatch-refused
     svelte 5.57.1 loosened to ^5.57.1, pnpm install --frozen-lockfile:
       exit 1
     Error: ERR_PNPM_OUTDATED_LOCKFILE
PASS a11y/image-node-refused
     the same install on the image's own Node, without the pinned Node on
       PATH: exit 1
     Error: ERR_PNPM_UNSUPPORTED_ENGINE
PASS a11y/toolchain-readback
     node v26.10.0, pnpm 12.6.0, @playwright/test 1.63.0, axe-core 4.13.0,
       @axe-core/playwright 4.13.0, svelte 5.57.1, vite 8.3.1
     chromium-headless-shell revision 1243 in /ms-playwright: present True;
       launched 153.0.8010.12
PASS a11y/browser-revision-absent-refused
     browserType.launch: Executable doesn't exist at
       /work/no-browsers/chromium_headless_shell-1243/...
PASS a11y/suite
     11/11 tests passed; exit 0
     axe-core 4.13.0 on Chromium 153.0.8010.12, tags wcag2a, wcag2aa,
       wcag21a, wcag21aa, wcag22aa, no impact filter: violations 0,
       incomplete 0, passes 22, inapplicable 41
     target-size executed: passes, 3 targets

Two replays from the review

The delivery's review found two ways the gate could report the wrong thing, and both were replayed on this host against the fixed gate, each in a scratch copy of tools/ and ui/ with a copy of the cache, so neither touched the checkout.

An expected failure. The review added test.fail(); to the 200% text test and made the lead paragraph clip (a fixed 700 px width, a 3 rem height, overflow hidden). The test then fails, Playwright records it as an expected failure, playwright test exits 0, and its JSON report still calls the spec ok; the gate as first delivered printed 11/11 tests passed and PASS. The fixed gate:

FAIL a11y/suite
     10/11 tests passed; exit 0
     10 of 11 tests passed; 11 are expected
     not a pass: 200% text scaling: no overflow and no clipped text, and the
       detector sees a planted clip (outcome ['expected'])
     text-scaling-200 records [[], '32px', ['p "This panel draws only from
       the P12 base " clips its content'], True]; the gate requires
       [[], '32px', [], True]
FAIL: 1 accessibility case(s) did not match their recorded outcome.

It exits 1, from the test outcome and from the measured report independently.

A store filled before a lockfile bump. The replay moved svelte from 5.57.1 to 5.57.0 in package.json and pnpm-lock.yaml, with 5.57.0's integrity from the npm registry, and kept the cache make a11y-fetch had filled for the committed lockfile. The gate as first delivered failed make verify-all there, at pnpm install --offline, with advice that pointed away from the cure. The fixed gate, exit 0:

SKIP: the offline pnpm store under <cache> was filled for another
  pnpm-lock.yaml (sha256 c2b89b1390d5, this checkout's is c6638cb0bfb3); run
  `make a11y-fetch`; the accessibility gate did not run.

On this host's own cache, which predates the marker, the first run after the fix printed a SKIP line saying the store records no pnpm-lock.yaml digest, and make a11y-fetch then recorded c2b89b1390d5297e06e73d46c144367652900763735d06e8870dd5b9752e2359, the sha256 of the committed pnpm-lock.yaml.

D81: the report per criterion

axe-core runs exactly the D81 tag set, wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa, with no impact filter: one violation of any impact fails the default state, and so does one incomplete result. The tag set alone makes axe-core 4.14.0 run target-size, which it ships disabled; the suite asserts from its own results that target-size evaluated the panel's three targets and passed.

For each criterion an executed rule is tagged with, the report names the clause in both editions from tests/en301549-clauses.json, whose every number was checked against the headings of the two standards at the digests the register cites. Clause 11 is non-web software; where V3.2.1 splits a clause into open and closed functionality, the open-functionality sub-clause is named. "Ran, no node" lists rules that ran and found nothing to evaluate.

WCAG V4.1.1 V3.2.1 Evaluated by Ran, no node
1.1.1 11.1.1.1 11.1.1.1.1 none image-alt and six more
1.2.2 11.1.2.2 11.1.2.2 none video-caption
1.3.1 11.1.3.1 11.1.3.1.1 aria-hidden-body, list, listitem six
1.3.5 11.1.3.5 11.1.3.5.1 autocomplete-valid
1.4.1 11.1.4.1 11.1.4.1 none link-in-text-block
1.4.2 11.1.4.2 11.1.4.2 none no-autoplay-audio
1.4.3 11.1.4.3 11.1.4.3 color-contrast
1.4.4 11.1.4.4 11.1.4.4.1 meta-viewport
1.4.12 11.1.4.12 11.1.4.12 none avoid-inline-spacing
2.1.1 11.2.1.1 11.2.1.1.1 none three
2.1.3 (AAA) 11.2.1.3 (Void) 11.2.1.3 (Void) none scrollable-region-focusable
2.2.1 11.2.2.1 11.2.2.1 none meta-refresh
2.2.2 11.2.2.2 11.2.2.2 none blink, marquee
2.4.1 11.2.4.1 (Void) 11.2.4.1 (Void) bypass
2.4.2 11.2.4.2 11.2.4.2 (Void) document-title
2.4.4 11.2.4.4 11.2.4.4 link-name area-alt
2.5.8 11.2.5.8 none in V3.2.1 target-size
3.1.1 11.3.1.1 11.3.1.1.1 html-has-lang, html-lang-valid html-xml-lang-mismatch
3.1.2 11.3.1.2 (Void) 11.3.1.2 (Void) none valid-lang
3.3.2 11.3.3.2 11.3.3.2 form-field-multiple-labels
4.1.2 11.4.1.2 11.4.1.2.1 eleven rules, button-name and label among them seventeen

Not covered by this gate: V4.1.1 11.2.4.11 (WCAG 2.4.11, Focus Not Obscured (Minimum)) and 11.2.5.7 (WCAG 2.5.7, Dragging Movements), both "none in V3.2.1". The suite asks axe-core itself, axe.getRules(['wcag2411']) and axe.getRules(['wcag257']), and both return no rule in 4.14.0.

The gate also covers REQ-P12-08, whose WCAG 2.2 AA pairing the tag set matches. D81 does not re-rule that row's V3.2.1 edition, and neither does this page.

The measured checks axe-core cannot make

axe-core 4.14.0 has no rule for the width or the contrast of a focus indicator, so the suite measures them from computed style (tests/probe.js): the focused element's outline, or a box-shadow ring when there is no outline, its width, and its WCAG 2.2 contrast against the colour it is drawn on. They bear on WCAG 2.4.7 (V4.1.1 and V3.2.1 11.2.4.7) and 1.4.11 (11.1.4.11 in both); the 200% text check bears on 1.4.4 (V4.1.1 11.1.4.4, V3.2.1 11.1.4.4.1).

Check Measured Verdict
Every tab stop, default tokens input, button, link: outline 3 px, 6.44:1 on white pass
Outline stripped (:focus-visible { outline: none }) no indicator, 0 px fails; axe-core still reports no violation
D16 width boundary token 3 px and 2 px pass
D16 width boundary 1 px fails
Contrast boundary ring #fa34ff on white, 3.000002:1 pass
Contrast boundary ring #b278ff on white, 2.989927:1 fails
Threshold alone 3:1 pass
Threshold alone 2.9999:1 and 2.99:1 fail
D76 stub theme 1 px fails the existing boundary test
D76 token overrides accent, surface, stroke, motion, ring width and colour each reaches the component
200% text font 32 px, no page overflow, no clipped box pass
200% text detector a planted 120 px clipping box detected

D16 fixes a 3 px token and a 2 px floor, so the boundary is the floor: 2 px passes and one pixel less fails, and the 3 px token passes above it. The stub in tests/fixtures/stub-heads-up-theme.css overrides one token and nothing else, which is what D76 asks the base tokens to allow; applied over them it lowers the ring to 1 px, and the existing boundary test fails (REQ-P12-10, negative case). tools/test_a11y.py holds the token file and the component's style block to the D76 rule: a colour, stroke or motion property with a literal value fails make verify-all, and the D17 rule too: the package declares exactly its six packages and no CSS framework.

Emulation is not portal or AT-SPI2 evidence

Three tests are labelled in their titles and in the report as what they are. The first two are the browser emulation D89 names:

  • [browser emulation, not portal evidence] reduced motion: with Playwright's reducedMotion: 'reduce' the motion token is zero and the button's transition reads 0s instead of 0.15s. No org.freedesktop.portal.Settings value is read.
  • [browser emulation, not portal evidence] forced colours: the ring stays 3 px at every stop and axe-core finds nothing.

The third is extra, labelled coverage that D89 does not name and that counts towards nothing:

  • [browser accessibility tree, not AT-SPI2]: the visually hidden token values stay in Chromium's accessibility tree, and the toggle reports [pressed]. No AT-SPI2 bus is queried.

Under D89 the two emulation tests cover only the CSS level of REQ-P12-01, REQ-P12-02 and REQ-P12-03. The portal half of REQ-P12-02, the AT-SPI2 half of REQ-P12-03 and the UKI gating of REQ-P12-01 are not met by M04. D89 assigns them to the milestones that own the shell and the image, M16 and M27, but no milestone carries them yet: M16's epics list none of the three, and its D77 criterion keeps the portal settings and AT-SPI2 on D-Bus mocks; M27 is the P17 VA-API slice; and M11 says only that the accessibility gate attaches when the UI enters an image. The same holds for REQ-P12-06's scan of the Forum Shell, which M04 runs on the P12 component only. Decision D91 (docs/roadmap/README.md) since assigns REQ-P12-06 to M16 (epic E16-4, the same gate over ui/forum-shell) and REQ-P12-01 to M11 (epic E11-6, no image/UKI build request without a PASS here). The portal half of REQ-P12-02 and the AT-SPI2 half of REQ-P12-03 stay open and owned by no milestone, listed with P12's activation blockers in planning/components.json and P12's row in docs/roadmap/inventory.md, until a live-session P05 milestone is planned.

On 2026-09-29 D101 (ADR-0004) removed the premise of that assignment: there is no ui/forum-shell to scan. E16-4 is now an AccessKit tree check over the native shell's exported tree, E11-6 gates the build request on this gate's PASS and, once M16 delivers it, that tree check, and M29, the live-session P05 milestone planned the same day, owns the portal half of REQ-P12-02 and the AT-SPI2 half of REQ-P12-03.

Renovate tracks the pins forward

renovate.json reads the image and the Node release out of toolchain.pin.json with two regex managers, and groups @playwright/test with the image, engines.node with the pinned Node, and packageManager with engines.pnpm. Renovate 44.101.2, run locally as renovate --platform=local --dry-run=lookup against a copy of the package on 2026-09-28, extracted every one of them: the six npm packages, engines.node 26.10.0, engines.pnpm and packageManager 12.6.0, the image with its digest and the pinned Node, each with no update, because each is the newest release. Against a copy with Node 26.8.2 and the image at v1.62.0-noble it proposed 26.10.0 on the branch renovate/node-for-the-accessibility-gate and v1.63.0-noble with its digest on renovate/playwright-and-its-container-image.

D65 tracks the latest release line, not the LTS one, and Renovate on its own would not. The node-version datasource marks every release of a line that has not reached LTS as unstable, and node versioning does the same, so from the moment 26.x enters LTS on 2026-10-28 Renovate would offer the gate no Node 27 until that line reaches LTS too, about six months after its release. renovate.json therefore sets ignoreUnstable to false for node in this package. Run against a copy pinned to 24.21.0, an LTS release: without that setting Renovate proposed nothing for either Node field; with it, it proposed 26.10.0, the Current line, for engines.node and the pinned Node together, on renovate/major-node-for-the-accessibility-gate. Setting versioning to npm instead proposed nothing, because the datasource's own marking still applies. It cannot refresh the Node tarball's sha256, so such a pull request fails make a11y-fetch until someone records the new digest from the signed SHASUMS256.txt: Renovate proposes, the gate proves (D69).

That happened on 2026-10-13 for Node 26.11.1 (released 2026-10-07): the sha256 3883bfc7… of node-v26.11.1-linux-x64.tar.xz was read from the signed SHASUMS256.txt and recorded in toolchain.pin.json. make a11y-fetch then downloaded the tarball and kept it only because it hashed to that pin, and run r20261013T234909-5e6b of make verify-a11y on podman 6.1.4 exited 0 with 23 PASS lines and no SKIP: a11y/pin printed node 26.11.1, a11y/toolchain-readback printed node v26.11.1, and a11y/suite passed 11 of 11 tests. The readback block above is the first run, kept as recorded.

Node 26.12.0 (released 2026-10-14) followed on 2026-10-16. Its release SHASUMS256.txt is signed by another releaser, Richard Lau, whose key C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C is listed in the keys.list of nodejs/release-keys; both the detached SHASUMS256.txt.sig and the clearsigned SHASUMS256.txt.asc verified against it in a fresh keyring, so checksums-signed-by in toolchain.pin.json moved with the sha256 fd47865e… of node-v26.12.0-linux-x64.tar.xz. Run r20261016T205235-c0f7 of make verify-a11y on podman 6.1.4 exited 0 with 23 PASS lines and no SKIP: a11y/pin printed node 26.12.0, a11y/toolchain-readback printed node v26.12.0, and a11y/suite passed 11 of 11 tests.

pnpm 12.11.2 (released 2026-10-09) followed on 2026-10-14. Its @pnpm/exe.linux-x64 integrity sha512-FK7QKe24… is the one pnpm-lock.yaml records; make a11y-fetch downloaded the binary and kept it only because it hashed to that integrity. Run r20261014T000123-9843 of make verify-a11y exited 0 with 23 PASS lines and no SKIP: a11y/toolchain-readback printed pnpm 12.11.2, and a11y/suite passed.

pnpm 12.14.0 (released 2026-10-15) followed on 2026-10-16. Its @pnpm/exe.linux-x64 integrity sha512-F6K8e/jW… in pnpm-lock.yaml equals the dist.integrity the npm registry serves for that version; make a11y-fetch downloaded the binary and kept it only because it hashed to that integrity. Run r20261016T210839-ca98 of make verify-a11y exited 0 with 23 PASS lines and no SKIP: a11y/toolchain-readback printed pnpm 12.14.0, and a11y/suite passed 11 of 11 tests.

Playwright 1.64.0 (released 2026-10-07) with its image followed on 2026-10-14. The linux/amd64 manifest sha256:bc72a8df… was read with skopeo inspect --raw from the image index sha256:06a9939e…, and the gate's first run on the new pin failed a11y/toolchain-readback, as it must: it read back revision 1248, Chromium 156.0.8078.4, against the old pin's 1243 and 153.0.8010.12. Those two figures were then written to toolchain.pin.json from that readback. Run r20261014T001558-da59 of make verify-a11y exited 0 with every a11y case PASS and no SKIP: a11y/toolchain-readback printed @playwright/test 1.64.0 and revision 1248, and a11y/suite passed 11 of 11 on Chromium 156.0.8078.4 with 0 axe-core violations.

The non-major group followed on 2026-10-14: svelte 5.57.2 (2026-10-06), vite 8.3.4 (2026-10-08), the Cargo lockfile and Rust 1.99.0 with clippy 0.1.99 and rustfmt 1.10.0. axe-core stays at 4.13.0. A first run with axe-core 4.14.0 showed that the suite printed axe-core 4.13.0: @axe-core/playwright 4.13.0 depends on axe-core 4.13.0 and injects that copy into the page, so a top-level 4.14.0 is installed but never executes. axe-core 4.14.0 (2026-10-05) is admitted once an @axe-core/playwright release that depends on it arrives through the update bot.

a11y/suite now compares the axe-core that executed with the axe-core pin in package.json and fails when they differ (engine_problems in tools/verify_a11y.py, covered in tools/test_a11y.py). Run r20261014T010826-02cb of make verify-a11y exited 0 with every a11y case PASS and no SKIP: a11y/toolchain-readback printed axe-core 4.13.0, @axe-core/playwright 4.13.0, svelte 5.57.2 and vite 8.3.4, and a11y/suite passed 11 of 11 with axe-core 4.13.0 on Chromium 156.0.8078.4.

axe-core 4.14.0 followed on 2026-10-16 with @axe-core/playwright 4.14.0 (released 2026-10-15), which depends on axe-core ~4.14.0 and so injects the admitted version. Compared with 4.13.0, axe.getRules() returns the same 105 rules; label-content-name-mismatch (WCAG 2.5.3) lost its experimental tag, so the D81 tag set now runs it, and target-size gained the EN 301 549 V4 tags and still ships disabled. Neither version has a rule tagged wcag2411 or wcag257. Run r20261016T225223-b066 of make verify-a11y exited 0 with 23 PASS lines and no SKIP: a11y/toolchain-readback printed axe-core 4.14.0 and @axe-core/playwright 4.14.0, and a11y/suite passed 11 of 11 with axe-core 4.14.0 on Chromium 156.0.8078.4 and 0 violations; 42 rules were inapplicable, one more than under 4.13.0.

Playwright 1.64.1 (released 2026-10-14) with its image followed on 2026-10-16. skopeo inspect --raw resolved the tag v1.64.1-noble to the image index sha256:7852dc5a…, the digest the update bot proposed, and read its linux/amd64 manifest sha256:bdfda2a8… from that index. The browser did not move: playwright-core 1.64.1's browsers.json names chromium-headless-shell revision 1248, Chromium 156.0.8078.4, as 1.64.0's does. Run r20261016T220720-71ec of make verify-a11y exited 0 with 23 PASS lines and no SKIP: a11y/toolchain-readback printed @playwright/test 1.64.1 and revision 1248, and a11y/suite passed 11 of 11 on Chromium 156.0.8078.4 with 0 axe-core violations.

The HISS lint (M16, D100)

praetorctl audit scans no JavaScript or Svelte (cordanaLLM/praetor#589), so M16 runs ESLint over ui/concordia-tokens inside this gate, right after the offline install and before the build: pnpm exec eslint --max-warnings 0 --format json, in the same digest-pinned container with the network off. The configuration, eslint.config.js, sets max-lines-per-function 60, complexity 10, max-statements 50, no-eval, no-implied-eval, no-new-func and the local rule aegis-hiss/no-self-recursion, and switches inline configuration off, so an eslint-disable comment is itself a finding. Mutual recursion is not detected: there is no call-graph check. The three packages are admitted on docs/roadmap/toolchain-admission.md and read back inside the container by a11y/toolchain-readback.

The gate reads ESLint's JSON report rather than its exit code alone. The package case requires exit 0, no finding, no suppressed message, at least one Svelte file, and every .js, .mjs, .cjs and .svelte file of the package outside node_modules, dist, test-output and the plants among the linted files, so a source ESLint silently skipped fails. A plant case lints one file with --no-ignore and requires exit 1, a report covering exactly that file, and exactly the findings its row in LINT_PLANTS lists; a crash exits 2 and fails. The boundary file must exit 0 with none.

Run r20260929T085301-5dfc on podman 6.1.2, 18 seconds, 22 PASS lines, the nine lint cases among them:

PASS a11y/hiss-lint
     eslint --max-warnings 0 over ui/concordia-tokens: exit 0; 10 files
       (9 JavaScript, 1 Svelte), 0 findings, 0 suppressed
PASS a11y/hiss-lint-function-length-refused
     hiss-lint/plants/function-length.js: exit 1;
       findings ['max-lines-per-function']
PASS a11y/hiss-lint-complexity-refused
     hiss-lint/plants/complexity.js: exit 1; findings ['complexity']
PASS a11y/hiss-lint-statements-refused
     hiss-lint/plants/statements.js: exit 1; findings ['max-statements']
PASS a11y/hiss-lint-dynamic-execution-refused
     hiss-lint/plants/dynamic-execution.js: exit 1;
       findings ['no-eval', 'no-implied-eval', 'no-new-func']
PASS a11y/hiss-lint-self-recursion-refused
     hiss-lint/plants/self-recursion.js: exit 1;
       findings ['aegis-hiss/no-self-recursion' x 4]
PASS a11y/hiss-lint-svelte-component-refused
     hiss-lint/plants/component.svelte: exit 1;
       findings ['aegis-hiss/no-self-recursion', 'no-eval']
PASS a11y/hiss-lint-inline-disable-refused
     hiss-lint/plants/inline-disable.js: exit 1;
       findings ['<ignored inline directive>', 'no-eval']
PASS a11y/hiss-lint-at-the-limits
     hiss-lint/plants/at-the-limits.js: exit 0; findings []
PASS a11y/toolchain-readback
     lint: eslint 10.11.0, eslint-plugin-svelte 3.23.0,
       svelte-eslint-parser 1.8.1

The lint's three packages moved together on 2026-10-13 (eslint 10.12.0, eslint-plugin-svelte 3.23.1, svelte-eslint-parser 1.9.0), after make a11y-fetch refilled the offline store for the new lockfile. Run r20261013T230135-93af of make verify-a11y on podman 6.1.4 exited 0 with 23 PASS lines and no SKIP: a11y/hiss-lint linted the same 10 files with 0 findings and 0 suppressed, every plant failed with exactly its findings, a11y/suite passed 11 of 11 tests, and a11y/toolchain-readback printed lint: eslint 10.12.0, eslint-plugin-svelte 3.23.1, svelte-eslint-parser 1.9.0. The block above is the first run, kept as recorded.

The first lint over M04's own suite found a real defect: tests/probe.js was one immediately invoked function of 170 lines. It is now a strict-mode block, so its declarations stay block-scoped and nothing it measures changed; the suite's eleven tests pass on the same run. Replayed with the original probe.js restored in the worktree, run r20260929T085327-8f83 failed a11y/hiss-lint alone, with ESLint's one finding Function 'install' has too many lines (170). Maximum allowed is 60. at tests/probe.js:15, and make verify-a11y exited 2; the fix was put back before any other run. A second replay appended a function that calls itself and eval to tests/harness.js: run r20260929T085750-104b failed a11y/hiss-lint alone, with no-eval and aegis-hiss/no-self-recursion at tests/harness.js:131, while the suite still passed 11 of 11, and the file was restored. So a violation in committed code fails the gate, not only the planted files the negative cases lint on their own.

The lockfile gained the lint's closure and nothing else: pnpm 12.6.0, from the cached @pnpm/exe.linux-x64 binary whose integrity the lockfile records, ran pnpm install --lockfile-only in a copy of the package, and every package M04 had locked kept its version and integrity. make a11y-fetch then refilled the offline store and recorded the new lockfile's sha256, 524186ac6373c69102e2f1239de3bb8ba5dad6c8602f13bc0d29d82962ad6cb7; until a cache holds that digest, the gate skips and names make a11y-fetch, as it does for any lockfile change.

On 2026-10-13 the same binary ran pnpm update source-map-js --lockfile-only in a copy of the package, which moved source-map-js, a dependency of postcss, from 1.2.1 to 1.2.2 and changed nothing else: the Verification gate's trivy step refuses CVE-2026-93749 (HIGH, fixed in 1.2.2) in the development dependencies it scans. The lockfile's sha256 is now 6205f77247f487deb19ee8f3be70bfcda9b625abc7ea590ba7f884d0d7842db1.

Scope, and what a pass here does not mean

  • It is one component on one page. The P05 shell, its canvas rows REQ-P05-09 to REQ-P05-15 and the D76 heads-up theme are not built here.
  • Browser emulation is not the desktop: see the section above.
  • It builds no image, gates no UKI and closes no accessibility gate for the shell or the image; make verify-all's last line says so.
  • P12 stays a proposal (D86): tools/verify_preparation.py binds activation to a Cargo manifest, and M04 does not change that.
  • The @sveltesentio/* packages D10 adopts are not declared here (D87), and since D101 the native P05 shell declares none either.
  • The D100 lint is HISS for this package's JavaScript and Svelte, not an accessibility check, and it stands only until cordanaLLM/praetor#589 ships a scanner.