Accessibility of every surface this tool emits

Audited 2026-09-02, whole, for the first time. Individual fixes had landed before this — a screen-reader user had no heading route to the failures in the longest section of a report and got one; explain was stopped from forging headings; the index label convention avoids task_id N because the underscore is announced. Nobody had gone through every surface once.

This page is the record: what each surface does, what was broken, what was fixed, what was handed back, and the places where accessibility and precision pull against each other and were not silently resolved.

The requirement is not decorative. The founder reads everything this tool emits through a screen reader, and it is also the product's own argument: a forensic report is read aloud, quoted in a letter, and pasted into a filing, so text that only works visually is broken output rather than ugly output.

The properties are pinned by engine/tests/test_accessibility.py (the text surfaces) and engine/tests/test_report_accessibility.py (the HTML report). Every check in both names, in its docstring, the mutation that makes it fail.


The surfaces

Surface Where it lives Verdict
Markdown report forensic/disclosure.py Report.render Sound. Two defects found and fixed; one handed back
HTML report forensic/html.py Sound. Nothing new found
JSON record Report.to_json, every to_dict Sound, and gained a word for a state that had only a sign
explain trace cpmcore/explain.py, cli._explain The best surface here. Nothing found
index listing cli._index Sound
check, validate, record, analyse, concurrency, tia cli.py All render through Report.render; audited as one
--help, top level and per subcommand cli.build_parser Sound. One tension, below
Errors and refusals cli.py, LoadError, InputError Six of eight read as sentences; two are broken and handed back
forensic.evidence verify forensic/evidence.py One warning dumps a Python repr; a stated tension, not fixed
demo/ demo/ Renderings of the above. Out of this agent's scope; inherits both the fixes and the current staleness

What each surface does well

Recorded because a later change can lose it, and because most of it was deliberate rather than lucky.

Headings. All five report-emitting commands open at # and never skip a level. Verified on the rendered output of check, index, validate, record and explain, not on the renderer's source. A bold line is never used where a heading is meant.

Figures carry their populations, adjacent. The three shapes below are examples of form, and their digits are historical and must not be quoted as this engine's figures -- 67.80% in particular is a superseded agreement headline, kept here because the sentence is about layout. engine/quality/FIGURE-BINDING.md names the owner of every live figure and the owner wins. 88% of the 32 clauses that were scored, and those 32 are 27% of the 120 clauses that apply. 67.80% of 584,687 measured field comparisons. score 89%, over 9 clauses decided of 107 applicable (coverage 8%).

Every percentage in the five rendered surfaces was read. One place does not restate its own population, and it is defensible rather than a defect: the P6 agreement limitation names 396,440 of 584,687 at the head of the paragraph and then traces a four-step movement — 61.89% to 67.50% to 67.54% to 67.71% to 67.80% — whose terms are all over that same corpus. Restating the denominator five times would make the sentence about the movement harder to follow, and the movement is what that sentence is about. ~~Recorded here because a listener arriving mid-paragraph by list navigation does not get the head of it. Not changed.~~ Superseded 2026-09-03: the chain is now its own bullet, so a listener does arrive at it by list navigation and the concern is no longer hypothetical. The denominator is still not restated inside it, and that judgement stands unchanged — the entry opens This figure rose from 61.89% in four steps, which names what the terms are of. See §"The check that could not see it".

Verdicts are words. passed, failed, NOT_EVALUATED, Referred, [from your file], [from our arithmetic]. Nothing is a tick, a badge or a colour. ~~The HTML stylesheet declares no colours at all, so there is nothing a colour rule could be the sole carrier of.~~ Superseded 2026-09-03: the stylesheet now declares a palette and a dark mode, and the guarantee is structural instead of an absence — the renderer emits no class and no style attribute, so a failing clause and a passing one are the same markup and there is no selector by which a colour could differ. Argued in full in §"The report as a document" below, including why this is a genuine trade rather than a free win.

No tables. Not one surface emits a table — the report is headings, prose and nested lists throughout, including the index listing, where a table would have been the obvious choice and each row is instead a sentence that stands alone. html.py implements no table construct, and test_report_accessibility.py fails if tabular material reaches the reader as a paragraph of pipes.

Runs of speech do not grow with the schedule. test_report_accessibility.py asserts that the longest evidence line does not lengthen with the activity count — the property that stops a 20,000-activity file emitting a single 6,414-character list item naming 756 identifiers.

explain is the model the rest should follow. Numbered steps, one fact per line, and every link stated in words: "A finishes 2026-01-09. The link A to B is FS with no lag. That permits B to start no earlier than 2026-01-10."

Refusals say what to do next. p6-23.12-xval.oracle.xer holds no project named 'NOPE'. It holds 13: … Name one of those with --project. 'TIA' could mean MIP 3.6, 3.7. Was the delay modelled against one base schedule (3.6) or against a separate contemporaneous update per event (3.7)?

The HTML report. lang="en"; one <main> holding everything; real h1h4 with stable, derived ids; a <nav aria-label="Contents"> built from the headings actually emitted; the provenance block as a <dl> so "Project" is announced as a label; the governing documents as an <ol> because their order is the precedence; no fixed pixel font sizes and no locked viewport; no floats, no positioning, no flex or grid reordering, so source order is reading order; no scripts, no external assets, and it reads correctly with CSS off because it is semantic HTML with a stylesheet, not styled divs.


What was broken, and fixed

1. The direction of a relation was carried by -> alone

relationship 9203 -> 9204 references an activity outside this project. XV01: A -> B -> C. Circular logic: A -> B -> C -> A.

-> is two ASCII characters. A screen reader announces them as "dash greater" at one punctuation setting and as nothing at another — at which point the sentence becomes "relationship 9203 9204", and the loop becomes an unordered list of three activity names. The direction is the whole of what the glyph carries, and in a forensic finding the direction is the finding.

explain had already been written the other way — "The link A to B" — so the fix is the existing house form applied to the surfaces nobody had read aloud.

Changed to to for a relation and then for a chain, at the thirteen sites that reach a rendered report:

Pinned by test_no_report_carries_a_relation_or_a_count_in_a_symbol, over all five Markdown surfaces. tests/test_graph.py pinned the old string and was updated with a comment saying why.

This makes the committed demo/ renderings stale, because every report reproduces the importer's notes. demo/ was owned by another agent during this audit and was not regenerated here. test_demo was already failing on six staleness cases for unrelated reasons; the arrow change adds to that set and the fix is the same one — python demo/run.py, once the other edits land.

2. A negative concurrency figure was the sole marker of a failed premise

forensic.multibase.WindowImpact.concurrency_days is the sum of the individual impacts minus the combined one. On a mixed-calendar network it comes out negative — which is not an overlap running backwards but the premise of the subtraction failing.

Until this pass the minus sign was the only thing that said so. MultiBaseResult.total_concurrency_days summed the windows and published the result, and nothing in the Markdown or the HTML mentioned it at all: a report over a failed premise and a report over four real days of concurrency read identically to a listener, and to a sighted reader who did not notice one character. Worse, a positive total can hide a failed window inside it — two windows at +6 and −4 publish 2, which reads as two days of concurrency and is not a concurrency.

cpmcore.modelled.ModelledResult and forensic.subtractive_multibase.PeriodCollapse — the other two callers of the same subtraction — already name the state in a word and already carry concurrency_prose, which was written for exactly this reason and says so: "because a report is read by screen reader as often as by eye, and a distinction carried by one character in a table is not a distinction." multibase was the third caller and had neither.

Added, mirroring subtractive_multibase exactly:

Pinned by test_a_negative_residual_is_named_in_words_and_not_only_by_its_sign and test_a_report_over_a_failed_window_carries_the_reason_the_reader_hears, which includes the +6, −4 → +2 case.


What was found and not fixed, and why

engine/src/forensic/cli.py was being edited by another agent for the whole of this audit — it went dirty between the first git status of this session and the first edit, and stayed dirty. Both remaining defects live in it. They are one line each; the diffs are below, and both are pinned by xfail(strict=True) tests, so whoever applies a fix is told to delete the marker.

3. A repetition count carried by ×

cli._import_notes, around line 1149:

count = f" ×{len(entries)}" if len(entries) > 1 else ""

renders `XER.TASKPRED.DANGLING` ×11 (warning): …, which announces as "code times eleven" — a count wearing an arithmetic operator, and at some punctuation settings a count with no operator at all. Suggested:

count = f" ({len(entries)} occurrences)" if len(entries) > 1 else ""

Pinned by test_no_report_carries_a_repetition_count_in_a_multiplication_sign, xfail-strict over check, record and validate.

4. A Windows path reaching the reader with every separator doubled

Two sites interpolate an OSError directly:

# cli.py ~313, in _load
raise LoadError(f"{path.name}: {exc}") from exc
# cli.py ~1359, in _read_json
raise InputError(f"could not read {what} from {path}: {exc}") from exc

str(OSError) puts the filename through repr, so the message is

missing.xer: [Errno 2] No such file or directory: 'C:\\Users\\charl\\...\\missing.xer'

— announced backslash by backslash, twice per separator, before the file name. This is the defect the forgery fixes elsewhere in this engine avoid by flattening rather than escaping. It is also the only refusal in the tool that does not say what to do next.

The path is already named by both call sites, so the part of the exception worth carrying is its reason:

raise LoadError(f"{path.name}: {exc.strerror or exc}") from exc
raise InputError(f"could not read {what} from {path}: {exc.strerror or exc}") from exc

Pinned by test_a_refusal_reads_as_a_sentence_and_does_not_escape_a_path, xfail-strict on the missing-file and missing-delays cases; the other six refusals pass.

5. -> as "before became after", outside the report surfaces

Twenty-odd further sites write X -> Y meaning was X, now Y — the worse class, because read aloud duration 5 -> 7 working days becomes "duration 5 7 working days" and the two numbers are indistinguishable. They are in cpmcore/compare.py, cpmcore/modelled.py, forensic/dynamic_observational.py and the update-comparison rules in forensic/rules/aace29.py.

Not fixed here for two reasons and both are stated rather than argued away: modelled.py and several of the test files that pin those strings were owned by other agents tonight, and none of these sites reaches a report unless two or more schedule files are supplied, which the audit fixture does not do. They are a real defect and they are still there. The fix is the same one word: {was} -> {now} becomes {was}, now {now}.


6. forensic.evidence verify dumps a Python repr into a line read aloud

The report tells a third party to check the record without trusting us: python -m forensic.evidence verify <record> <schedules>. That command is a surface, and it prints:

WARNING REPRO.MANIFEST.UNCHECKED the record's manifest states
calendars={'8001': {'clndr_type': 'CA_Base', 'hours_per_day': 8.0, 'id': '8001',
'name': 'XVAL-MONFRI', 'pattern': {'extra_work_days': [], 'holidays': [],
'working_weekda... (829 chars), options={'data_date_convention': 'through', ...
(1105 chars), and this verifier does not check any of it

Read aloud, that is several hundred announcements of quote, brace, colon and underscore before the sentence that matters. It is barely usable by eye either.

Not fixed, because the values are the point. _abbreviated was written the way it is deliberately: the warning exists because a record forging jurisdiction: "texas" or options: {"retained_logic": false} reproduced cleanly, and the fix was to name what was claimed and not checked. Summarising options as "nine keys" or calendars as "three calendars" would delete the half of the warning that does the work. Naming the field and its value is right for method, where the values are short; it produces a wall for options and calendars, where they are not.

A defensible split — values in full for method, and for the two long fields the keys plus a pointer to the record — would need the judgment of whoever wrote that warning, and changing it on accessibility grounds alone risks weakening a control this repository built after measuring a forgery pass. Recorded as a tension, deliberately unresolved.


Tensions between accessibility and precision

Written down rather than resolved. This product's output is evidence, and precision outranks fluency; each of these is a place where making the text read better would make it say less.

Relationship type codes. The link A to B is FS with no lag announces as "F S". finish-to-start reads better. It is not obviously better output: FS is the string P6 shows, the string the file stores, and the string a reviewer matches on when checking our finding against their own schedule. Expanding it puts our vocabulary between the reader and the file. Unresolved. Left as FS.

The section sign. §3.3.11 is announced as "section" by most readers and skipped by some, which would leave "3.3.11". Spelling it "section 3.3.11" is more listenable — and every finding in this tool is written to be pasted into a letter, where §3.3.11 is the form the specification itself uses and the form the recipient will look up. Unresolved. Left as §.

Digests. sha256:1b98626707918c46898550e5ae276f87763d859dfa7fd666fb4ffb722fe3b50f is 64 characters of hexadecimal read aloud one letter at a time, and there is no version of that which is pleasant. Truncating it would falsify the evidence: the whole point of the input digest is that a third party can recompute it. It is at least announced — every occurrence is preceded by a label (Input digest, file digest, model digest, answer digest) and the algorithm prefix, so a listener knows what is coming and can skip the line. Unresolved, and the current form is judged correct.

--help length. check --help is about sixty lines because every option's help is a full sentence explaining what is not assumed. That is the right trade for a reader who can skim and an expensive one for a reader who cannot. No fix attempted: shortening it would delete the disclosure the tool exists to make. Worth revisiting as a --help / --help-full split, which is a product decision and not an accessibility one.

-- used as an em dash in prose. Several rule messages punctuate with -- where the report elsewhere uses . Both are announced as nothing, so this is consistency rather than accessibility, and it is not worth a sweep through twenty files.


Could a blind reviewer use this product end to end today?

Yes, with two exceptions, both of them in error messages rather than in reports.

The evidence is five surfaces rendered and read end to end — check, index, validate, record and explain, in Markdown, HTML and JSON — plus the section bodies of analyse, concurrency and tia read in source, since all eight render through the same Report.render. On that evidence: every report is navigable by heading, states every figure with its population, carries no meaning in colour, position, indentation or a glyph, emits no tables, and does not grow an unbroken run of speech with the size of the schedule. The HTML report is a self-contained semantic document that works with CSS off. The explain trace is genuinely good: a listener gets the same argument, in the same order, as a reader.

The exceptions are the two refusals in §3 and §4 above, and the verifier's repr wall in §6, which is a usability defect for everyone and worse for a listener. A reviewer who mistypes a filename gets a message with a doubled-backslash path and no next step, and every report reproducing a repeated importer note gets ×11. Neither withholds information; both cost the listener something a sighted reader does not pay. Both are one-line fixes waiting on a file being edited elsewhere.


The report as a document

Added 2026-09-03, a separate pass with a different question. The audit above asks whether the report can be used. This asks whether it is a document a scheduling expert would be glad to receive and a partner would attach to a claim. Nothing above is undone; three of its properties are strengthened and one is deliberately weakened, and that one is argued at length in §4 because it is the only thing this pass gave up.

The premise is that the HTML report is the only thing a customer sees. It is printed, emailed, quoted in letters and put in front of an opposing expert. The stylesheet grew from 24 lines to about 130. There is still no JavaScript, no network fetch, no external font and no second file.

Not one word of what the report says changed. Report.render() remains the single place that decides what a report contains. test_html.py::test_the_markdown_and_the_html_carry_the_same_heading_structure still fails if a section exists in one renderer and not the other; it was changed to excise the contents <nav> before comparing, which makes it a stricter statement of that property rather than a looser one.

1. Typography and measure

Before After Why
max-width: 46em 40em 46em of Georgia is ~95 characters. The return sweep costs accuracy past about 75, and this document is read straight through rather than scanned.
no type scale h1 1.9em, h2 1.4em, h3 1.12em, h4h6 1em Four levels were rendering at browser defaults, where h4 is smaller than body text. The verdict classes — Blocking failures, Referred — are h4.
serif throughout serif for prose, sans for headings, dt and the contents A reader skimming for a heading is doing a different job from a reader reading a finding.
dl runs into the text dl closed by a 2px rule The provenance block is a masthead. Which schedule this is, is the first thing a reviewer checks.
dd unbroken overflow-wrap: anywhere The input digest is sha256: plus 64 hex characters with no break opportunity in it. It ran off the right edge on paper and on a phone.
code unmarked tinted, padded, overflow-wrap: anywhere The rule id opening a finding is the scan target.

Every size is in em. test_the_reader_keeps_control_of_text_size fails on any px or pt in a font-size and still passes.

2. Print, measured rather than asserted

Rendered to PDF with msedge --headless=new --print-to-pdf over the real conformance report on oracle/cases/p6-23.12-xval.oracle.xer. 14 pages with the browser's own header and footer and 14 without, so the furniture does not reflow the content. The stylesheet was then stripped one rule at a time and re-rendered, because a print rule that does not move the artefact is decoration and this is the only way to find out which ones do:

variant pages bytes
as shipped 14 263,097
whole @media print block removed 15 293,766
@page margin removed 13 261,463
nav { break-after: page } removed 14 263,097
h2 { break-before: page } added 20 267,315

The first three are the rules working. The fourth is the one worth writing down, because a byte-identical PDF is what an inert rule looks like: the nav page break is live but currently redundant on this report — the contents card happens to fill page one, so the forced break falls exactly where the page would have broken anyway. The fifth arm proves forced breaks reach this engine at all, and a sixth settles it directly: with the contents shortened to three entries, keeping the break gives 251,897 bytes and removing it 251,306, so the rule does move the layout as soon as it has anything to move. Recorded rather than asserted, because on this fixture the check "does removing it change the PDF" answers no for a rule that is nonetheless correct.

li, p { break-inside: avoid } was in the print block and is removed. One limitation in this report ran to 1,878 characters, taller than a printed page, and break-inside: avoid on a block taller than its fragmentainer is either ignored or honoured by ejecting a near-empty page ahead of it, depending on the engine. Orphan and widow control is the rule that applies to prose of that length.

Updated 2026-09-03. That limitation is now nested bullets of at most 771 characters, so the block this rule was removed for no longer exists. The removal stands: identifier_limitation is still 900 characters, and the next long limitation somebody writes would put it back.

No browser implements generated content in @page. @bottom-center { content: … } is a Paged Media feature that Prince and Paged.js implement and Chrome, Edge, Firefox and Safari do not. A footer written in the stylesheet would not appear, and nothing would report an error.

What browsers do print in their own header and footer is the document <title> and the page number. So the identity went where the browser will actually print it: <title> is now Schedule conformance review — XV01, composed by html._document_name from the Project field of the provenance block. The <h1> is untouched — this is the document's name, not a line of the report.

The cost is stated rather than hidden. That footer is off by default in Edge's print dialog, and a user who leaves it off gets pages carrying no identity at all. The alternative — a repeating fixed-position element, which is what a JavaScript paginator would give — is refused twice over: position: fixed fails test_the_source_order_is_the_reading_order, and it is exactly the visual-only furniture this report does not carry.

The contents prints, which the brief asked it not to

Stated as a deviation. This <nav> is not screen furniture; it is a static contents list at the top of the document, which is what a document attached to a claim is expected to carry. It prints as page one, followed by break-after: page. It also gained a visible <h2>Contents</h2>, because aria-label renders as nothing and on paper an unlabelled indented list of section names has to be inferred. nav { display: none } in the print block reverses this in one line if a reviewer disagrees.

3. Citing a finding

Ninety-two findings in the conformance report now carry an id derived from the rule id and from nothing else:

<li id="finding-ufgs-034"><code>UFGS-034</code> §3.3.8.1 — the schedule contains 0 …

Position is the trap, and it is a quiet one. #finding-4 still resolves after one more clause fails and the report is re-run. The letter then cites a different finding and nothing anywhere reports an error — the same shape as the scd_end_date defect CLAUDE.md records: a right answer for no reason, which is worse than a wrong one.

Three checks hold it, in test_report_accessibility.py:

4. Colour, and the property that was given up

Before today the stylesheet declared no colours at all, and the predicate asserted exactly that. It was the strongest available form of "colour never carries meaning" for as long as it held. It stopped holding the moment the report gained a palette and a dark mode, and an absence cannot be strengthened; it can only be kept or lost. So the replacement is a property:

render_html emits no class attribute and no style attribute — not one, anywhere in the document. A failing clause and a passing one are therefore the same markup, and there is no selector by which a colour could differ between them.

That is structural rather than a promise. A stylesheet that wanted to paint failures red would first have to invent a hook that does not exist. Both halves are checked: colour_problems fails if the markup grows a class or style attribute, and fails if any CSS rule sets a colour through a class, id or attribute selector. Pseudo-classes (:hover, :focus-visible) are excluded deliberately — they describe the reader's pointer, not the document's state.

Because colour is now load-bearing for legibility though it carries no meaning, a third arm was added: every foreground the document puts on a ground clears WCAG contrast on it, in each of the three themes. The ratios are computed by the test, not asserted from this table:

ink on paper muted on paper ink on tint muted on tint
light 16.90 7.75 14.97 6.86
dark 14.13 6.95 12.64 6.22
print 21.00 12.63 18.76 11.29

Floors are 7.0 (AAA) for --ink and 4.5 (AA) for --muted. --rule, the hairline under a heading, is deliberately not held to a floor: it separates a heading from what follows and carries nothing, and a floor invented for it would be a rule written to be passed — the anti-pattern this repository has found four times.

Is this weaker than what it replaced? On its face yes: a ban on colour cannot be argued around. In substance it is stronger against the failure that matters, which was never "a colour exists" but "a colour is the only thing saying this failed". The old check could not have caught a report that grew class="fail" and was coloured elsewhere — a user stylesheet, an email client, a copy someone edited. The new one catches the hook. It is also the only version that survives the report having a dark mode at all, and the alternative was to refuse dark mode, which is a legibility feature for the low-vision reader this audit was written for. Recorded as a genuine trade.

5. Scanability

"What failed" is answerable in about ten seconds by four carriers, none of them colour: the contents card listing Blocking failures (4) with its count in the heading text; real heading levels, now visually distinct, so h4 verdict classes stop rendering smaller than body text; each finding opening with its rule id in <code>, so the left edge of the list is a column of identifiers; and the wordsfailed, Referred, NOT_EVALUATED, [from your file].

6. Dark mode

prefers-color-scheme: dark redefines five tokens and nothing else. It costs the print work nothing because the print block redefines them again, and it costs the accessibility work nothing because it is the same markup — see §4 for why that is a structural claim and not a reassurance. color-scheme: light dark was already declared; the palette makes the document itself follow.

What was measured

Tensions this pass did not resolve

The browser print footer is not under our control. The identity is in <title> because that is where a browser will print it; whether it prints is the user's dialog setting, and in Edge the default is off. There is no way for a self-contained, script-free HTML file to guarantee a page footer.

The contents prints, and the brief asked for it not to. Argued in §2. One line reverses it.

A finding's evidence lines are not separately addressable. Only the finding's own <li> carries an id, so a letter can cite #finding-ufgs-098 but not the third line of its evidence. Inventing ids for evidence lines would mean deriving them from position — the exact defect §3 is about — because an evidence line has nothing of its own to derive from.

Handed back — content, not presentation

  1. unvalidated_engine_limitation().what is one 1,878-character chain of clauses. It is the limitation an opposing expert reads most carefully, and as a single <li> it is one uninterrupted run of speech — the same shape as the 6,414-character UFGS-041 line that test_no_run_of_speech_in_the_report_grows_with_the_size_of_the_schedule was written for, except that this one is hand-written, so that test cannot fire on it. Not fixed here: changing it changes what the report says. Taken up and fixed 2026-09-03 — see "The check that could not see it" below. It had reached 2,263 characters by then.
  2. #### Not evaluated — one input away from deciding (74) is 74 items, about 30% of the document, and the least load-bearing section in it. Nothing is wrong with it; whether it belongs in the body or in an appendix is a content decision. Measured 2026-09-03 and handed back again, with a different recommendation than the obvious one — see "Grouping the abstentions" below.

Neither is a defect in the renderer.


The check that could not see it

Added 2026-09-03. Item 1 above was taken up. The reword was the smaller half of it; the check was the point, because a reword with no check regresses on the next correct addition, which is exactly how the paragraph reached 2,263 characters.

What the growth check covers, and what it cannot

test_no_run_of_speech_in_the_report_grows_with_the_size_of_the_schedule is sound and stays. It is blind to hand-written text in two independent ways, and both are deliberate:

So a check written for over-long runs of speech, in the file that owns that property, could not fire on the longest run of speech in the report. It was not a weak check; it was a check about a different half of the same defect, and nobody had written the other half.

The other half

run_of_speech_problems in test_report_accessibility.py, run over both jurisdictions and included in the broken-document control: no li, p or dd in the rendered HTML carries more than MAX_RUN_OF_SPEECH = 1,000 characters of its own text.

The number is derived from the listener rather than from the corpus, because a bar set by measuring what is already there is a bar written to be passed. A screen reader at a default rate runs about 180 words a minute and English averages about 5.5 characters a word including the space, so 1,000 characters is one minute of speech before the listener reaches a point they can stop, step back or skip from.

A ceiling is the right shape here and the wrong shape for generated text — the growth test's own docstring says why, and that reasoning is not being contradicted: a ceiling for generated output would have to sit above anything a large schedule can emit, at which point it stops being able to fail. A ceiling for typed prose is falsified by a person adding a clause, which is a real input and the one that produced this defect. The two checks hold two halves and each now says which.

What survives it is stated rather than hidden. The longest run left in the conformance report is identifier_limitation at 900 characters, 100 short of failing. That is a warning, not a margin.

The fix silently weakened the growth check, and that is written down

The repair for the content — nested - bullets, below — put a 775-character hand-written line at the indent the growth check selects on. Measured on the two fixtures, with nothing else changed:

selector small (300) large (4,000) delta bar
indent-only, as it was 775 775 0 16
lines the other report does not carry 143 146 3 16

A delta pinned at 0 is not total blindness, and the distinction is measured rather than assumed: re-planting the ', '.join(...) blob in rules/ufgs.py still fails under the indent-only selector, because at 4,000 activities that blob is longer than 775. What the floor bought was silence about any growth staying under 775 characters.

The selector is now what it always meant — lines this schedule size produced and the other did not. Prose is excluded because it does not change with the input, which is the property under test, rather than because of where it sits on the page.

The structure chosen for the limitation, and why not the other two

Limitation gained detail: tuple[str, ...], rendered as nested bullets under consequence at the indent removed_by already uses. Five entries: how the corpus is composed, what the gated subset says and why it is not the headline, where the residual disagreement is, the four-step chain, the MPXJ trade, and what the whole of it means for a figure taken out of this report.

The other two options were considered and are refused for stated reasons.

Not one word was removed and no figure moved. Two semicolons became full stops where a clause became its own entry. The chain's intermediate figures — 61.89 → 67.50 → 67.54 → 67.71 — stay frozen as literals and only the final step interpolates, unchanged by the split; test_no_live_agreement_figure_is_also_written_as_a_literal passes.

The rendered run went from one 2,263-character run to a 394-character headline (the what, the em dash and a 318-character consequence) and five entries of 261, 289, 771, 323 and 220. The longest run in the report is now identifier_limitation at 900.

Grouping the abstentions — measured, and the obvious design is refused

Item 2 above suggested grouping Not evaluated by the missing input, so a reader sees "supply the contract completion date and eleven of these answer". That was worth measuring before building, and the measurement does not support it in the form proposed. On the conformance fixture:

abstentions distinct missing inputs inputs that decide exactly one clause
us_federal 74 55 43
us_state_or_private 55 37 28

Grouping 74 items under 55 headings makes the section longer, not shorter, and 43 of those headings would introduce a single item. The story the section has is real but smaller than it looked: the five largest inputs decide 28 of the 74, and the largest single one decides 13.

So the shape that pays is not "group by what is missing". It is:

forensic/respond.py's _supply — now wired in behind --respond, so the "zero importers" note is out of date — is the right place for the contractor half of this and does not group either. Both it and the two render sites in cli.py were owned by other agents tonight and cli.py moved twice under this one, so nothing was changed and the sites are named by their code rather than by a line number. Handed back:

# cli._conformance_body -- currently `for f in report.not_evaluated:`
for f in sorted(report.not_evaluated, key=lambda f: (f.missing, f.rule_id)):

# the second renderer -- currently `... for f in skipped` in a comprehension
for f in sorted(skipped, key=lambda f: (f.missing, f.rule_id))

# forensic/respond.py `_supply` -- currently `for f in findings:`
for f in sorted(findings, key=lambda f: (f.missing, f.rule_id)):

Finding.missing is a sequence, so it sorts as a tuple and identical requests land together; the lead sentence stating the counts is the other half and is a sentence somebody has to write, not a sort.

A second finding, and it is content rather than presentation. asce.delay_events (13 clauses) and terms['aace.delay_events'] -- the delay events the analysis identified (5 clauses) are two spellings of one input the reader has to supply once. Under any grouping they become two requests for the same thing, 18 clauses apart. That lives in forensic/rules/, which is owned; not changed, and not a defect this pass created.

Demo staleness

This changes the rendered report and the committed demo/ renderings are now staler by one more cause. test_demo was already failing on six cases and the arrow change added to that set; the limitation split adds to it again. The diff on those six is currently **Date:** becoming **Date prepared:**, which is somebody else's change, so the set is unchanged in number and longer in causes. The fix is the same one — python demo/run.py, once the other edits land. The demo was not regenerated here.

What I would do next

Source: docs/ACCESSIBILITY.md. Source commit date: 2026-09-09.

See it in practice

Follow the evidence, from the schedule to the finding.

Explore the worked example