Layer 2 — Task rules Human-run today
An agent is built to drive your app for real across a task matrix — core flows (onboarding, search, checkout) times environment variants (dark mode, 200% font, VoiceOver on, different locales, low connectivity) — and to record every failed run step by step, so the customer does not ask "is this true?" and instead watches it.
7 of this layer's 9 rules are active in catalog 2.5.0, and every report contains their rows. They are performed today by a person on a physical device with VoiceOver on and the screen recorded, under a written protocol — not by the agent. So the rules ship and the evidence ships; what is planned is the execution: the agent driving the matrix instead of a tester.
Where each number was measured
Every row in the report names its environment — iPhone 17 Pro (simulator) or iPhone 13 (iOS 26.6, physical) — because a device name on its own reads as hardware, and most of the matrix is not. Layout, contrast, target sizes, Dynamic Type, dark mode and locales are deterministic and reproduce identically on hardware, so they run on simulators; that is what makes a matrix of ninety-six runs affordable instead of six.
Two things do not survive that translation: VoiceOver — the actual screen reader, not the accessibility API a simulator exposes — and behaviour under a poor connection. We will not publish a simulator number for either and call it a device result. If you invite our tester on TestFlight, we install your build on a physical device and measure them properly. If you do not, those checks are published as not testable, which lowers your coverage percentage rather than quietly inflating it. Either way the report says which happened.
A third case sits beside those two, and the report names it separately as instrument_limit. Some properties an accessibility rule needs are ones iOS never hands to an automated test on any build: whether the screen reader stops on a given element, whether a control is disabled as opposed to merely announcing that it is, and the words a control paints on itself. Checking those against themselves would produce a pass on every app ever written, so we do not. Those rows stay open, they cost coverage like any other blocked row, and they are answered by the human verification pass on a physical device that every audit already includes — there is nothing for you to re-send, because a second run would return the same blank. We keep the rules active rather than deleting them: a criterion we cannot automate is still a criterion your app has to meet, and quietly dropping it would answer a question nobody asked.
What you send us
One artifact: your app built for the iOS Simulator, zipped — what xcodebuild -sdk iphonesimulator produces, not an .ipa. A device build is compiled for arm64 hardware and signed for it, and a simulator cannot install one. So we refuse it in the app the moment you pick the file, rather than two days later inside a queue you cannot see.
xcodebuild -scheme YourScheme -sdk iphonesimulator -configuration Debug -derivedDataPath ./ap-build && cd ap-build/Build/Products/Debug-iphonesimulator && zip -ry YourApp.app.zip YourApp.app
If you would rather run one thing than assemble two, readyaudit-package ↗ does exactly this and nothing else. About a hundred lines of bash, MIT licensed, published as readable source on purpose: we are asking you to run a script against your own codebase while telling you it makes no network call, keeps no key and reports nothing home. That claim is only worth something if you can check it yourself in under a minute. Read it before you run it.
Your build is kept for 14 days after your report is delivered — for free re-runs and support, nothing else — and is then destroyed, with a signature you can check rather than a promise you have to take. What happens to it in between.
Which build a report is about
A version string is something a developer types. Two builds can both say 3.4.1 (204) and differ across the entire accessibility layer, so every report also carries a build digest: one number computed from the bytes of the bundle we actually drove. It answers one question and no other — is the artifact in your hand the artifact this report is about? It is not a signature and it proves no authorship. It is there so that a procurement reviewer, or you six months later, can hold an .app next to the PDF and get a yes or a no instead of a shrug.
That only works if you can recompute it without trusting us and without running our code. So the algorithm — readyaudit-bundle-sha256/1 — has no options, no salt and no timestamps, and it is written out here in full rather than summarised:
- Start from the extracted .app directory, never the zip. Zip bytes carry the compression level, the archiver's version and the order it happened to walk the tree. None of that is your program.
- Walk it recursively. Regular files and symlinks are included. A directory contributes nothing of its own — its children carry the paths. Anything else, such as a fifo or a device node, is a hard error rather than a silent skip.
- Skip any entry whose basename is exactly .DS_Store, at any depth. Finder writes those into folders you merely looked at, and a digest that moves because someone opened a window is a digest nobody trusts twice. It is the only exclusion, and naming it here is the point.
- Take each entry's path relative to the bundle root, with / separators and no leading ./. The .app's own name is not part of any path: renaming MyApp.app does not change the program, and a browser renames a second download for you.
- Hash each entry to lowercase hex SHA-256 — a regular file by its contents, a symlink by its target string, as bytes, unresolved and not followed.
- Record one character of mode: x for a regular file with any execute bit set, f for one without, l for a symlink. Nothing else about permissions, because nothing else survives a zip round-trip or a copy onto another volume, and a digest that changes when the file is merely moved is worse than no digest. That one bit is kept because an executable which has lost its +x will not launch, and that is a different artifact.
- Sort the entries by relative path, raw bytes ascending (LC_ALL=C). Not by locale, not case-insensitively.
- Build the manifest: one line per entry, hash, space, mode, space, relative path, newline. No header, no trailer, no count line.
- The digest is sha256: followed by the hex SHA-256 of that manifest.
Two numbers are published beside it and are not inputs to it: the file count, which is the number of manifest lines, and the byte count, which is the total hashed content length — for a symlink, the length of its target string. They exist so a mismatch can be diagnosed and not merely announced. A wildly different file count says you have a different build; an identical file count with a different digest says you have the same build, modified.
Four programs compute this number, and they are built to disagree loudly rather than quietly. Your Mac measures the bundle at intake, before anything is uploaded. The worker measures the extracted build it is about to drive. The API compares those two exactly once, at delivery, and deliberately never computes a third of its own — two independent measurements are a comparison, one is a restatement. The fourth runs somewhere we do not control, which is the only place that matters: your machine.
./verify report.json MyApp.app.zip
verify ships in the same readyaudit-package ↗ repository, needs only shasum and unzip, and exits 0 for the same artifact and 1 for a different one. A missing file or a bad argument exits 2 instead, on purpose: a tool whose failure mode looks like a verdict would tell you your build was wrong when you had only mistyped a path. And if you would rather not run our script at all, the nine steps above are the whole specification — reimplement them in whatever you already have open, and get the same number.
The digest is printed in full on every surface that carries provenance, never truncated to look tidier. A report without one does not exist: it fails schema validation before it can be delivered. And if a later audit of the same app arrives carrying a version string we have already seen with bytes we have not, the report states that on its face, with the earlier digest beside the current one — rebuilding without bumping a build number is ordinary practice, but it is the single fact that most often explains why an older report looks like it does not match the build in your hand.
Layer 3 — Narrative lens Planned
Six curated lenses — motor-limited, low-vision, first-time, impatient-expert, older, screen-reader — are built to interpret and prioritize the raw findings from Layers 1 and 2. The model narrates evidence; it never produces data or numbers. This layer is orchestrated but not shipped: a report generated today prints its findings without them.
How screens are grouped
The crawl records a screen every time it changes, so one screen arrives many times over: a list with new rows, a question card with a new question, the same view a second walk passed through. Something has to decide which of those recordings are the same screen, because the coverage denominator is rules × screens and a defect seen twice is one defect.
That decision is model-assisted and structurally verified, and we say so here rather than let you infer it. A model is shown the screenshots of one audit and asked to group them and name each group by function — paywall, onboarding-step, list. Then the accessibility trees overrule it: two screens the model called the same are split back apart if their control sets genuinely differ, and two it called different are merged if their trees are identical. Perception from the model, arbitration from the evidence.
What the model cannot do is anything else. It produces no finding, removes no finding, and changes no count in the report. Every check and every finding in your report comes from a deterministic rule reading the accessibility tree, exactly as the layers above describe. The grouping is also checkable by you: each screen type is printed with a representative screenshot and the number of recordings behind it, so a grouping we got wrong is visible on the page rather than hidden in a counter.
When the model cannot be reached, the audit waits in the queue and is re-run. It is never published with a fallback identity, because a coverage number built on a different grouping than the one we describe here would be a different measurement wearing the same name.
The reproducibility filter
Agent runs are not deterministic: the same task can take different paths. So the pipeline is built to run every runtime task three times and report the median, and to drop any runtime finding that does not reproduce in at least two of three attempts before it reaches your report.
You see the ratio on every card, and the ratio is not rounded up for presentation. A check read once from a single accessibility tree publishes 1/1 — reading one file three times would be theatre, and printing 3/3 for it would claim a reproducibility we did not earn.
Versioned rules
The rule catalog is versioned, and every report records the exact version it was audited against. When a rule changes, past reports still say what they meant. Today that is catalog 2.5.0: 74 rules, 21 of them active — 14 automated and 7 on the recorded device pass.
Why we do not report false positives
A finding you have to argue with costs more trust than it is worth. If a rule is not sure, it stays out of the report. We would rather under-report cleanly than pad a report with noise — the report has to be something you can forward to your team without a caveat.