This document records where the project intends to go, what each step is waiting on, and what has been considered and refused. It is event-driven rather than date-driven: most of what constrains this tool is outside it, and the calendar matters only where the calendar is the fact that matters (the annual publication of labels, and the effective dates of regulations).
Every item below is bounded by the commitments the tool already makes, which are recorded in the ADRs and enforced in code:
- Every check cites a published requirement that was fetched and read. No check enforces a rule this project wrote.
- Findings describe documents, never suppliers. Nothing here is a compliance determination.
- Fail closed. What cannot be evaluated is reported as such and can never resolve to a pass.
- Offline, deterministic, no model called, ever.
An expansion that would break one of these is not on this roadmap, however useful it would be. Several such expansions are listed under Refusals at the end, so that they are recorded rather than re-proposed every few months.
Status: waiting on an external event, expected within weeks of this writing.
Sections 1393.1(c) and 1393.1(c)(7) attach new disclosure duties to a 2026 trigger. Both requirements are registered and enforce nothing, classified conditional, because the published text does not settle whether the trigger attaches to the date a label was disclosed or to its data year, and because no label from data year 2025 or later existed to calibrate against.
The event that unblocks both: the Energy Commission publishing labels for data year 2025. Under section 1393.1(b)(2) those are due October 1, 2026.
Steps, in order:
- Read the first published data year 2025 labels through
scripts/fetch_examples.py, one at a time, under the usual limits. - Settle the trigger question in an ADR (
docs/adr/0010). Either the total-power-content section appears on labels whose title says 2026, or it does not, and whichever way the issued renderings answer it becomes the documented basis for the derivation. If the renderings answer it inconsistently or not at all, both checks stay registered and the ADR says why. - If the derivation holds: implement PCL023 and PCL024, move them out
of the conditional set, pin the change in
tests/test_registry.py, and record it in the changelog as an increase in the implemented count. - Calibrate against the wider 2025-vintage set as it accumulates under
Track B, since a check built on three labels is how false findings got
into this tool the first time (see
docs/adr/0006).
This is the largest single increase in the implemented count available to the project: two checks, both fully specified by published text, blocked on a document that does not exist yet.
Status: ongoing, no blocker. Thirty four of ninety one read as of 22 August 2026.
Twenty four of the ninety one published 2024 labels have been read. Each
widening so far paid for itself: the set going from three to eight exposed
two places where the tool reported an element absent from a label that
carries it, and both fixes are now core behavior (docs/adr/0006,
docs/adr/0008).
Steps:
- Widen in diversity-first batches, as before: pick the labels least
like the ones already read (supplier type, size, geography), not the
next ones on the list. Fetch through
scripts/fetch_examples.py, which honours robots.txt, rate limits itself, and refuses bulk runs. - Do the artwork enumeration and placement measurement for each new
batch (
scripts/inspect_artwork.py), and render pages only where doubt remains. Record what was done and what was not, asdocs/sources.mdalready distinguishes for the first eight and the sixteen after them. - Record the result per batch in
docs/sources.md: which checks deviate, which went unevaluated, whether the standing two deviations hold. A batch that contradicts the standing result is more important than one that confirms it, and gets an ADR if it changes tool behavior. - When the data year 2025 set publishes, start the same process on the new vintage. First questions for that vintage: does the statewide column still sum off its displayed total, does any label carry the section 1393.1(c)(6) statement (which would begin to unblock PCL029), and do the 2026-trigger elements appear (Track A).
No label is committed to the repository, and none will be. The calibration record lives in prose and hashes, not in binary fixtures.
Status: deliberately deferred. Activation trigger: a regulation amendment, or a second vintage whose expectations differ.
The tool encodes one ruleset and prints its identifier and effective date on
every report. Building multi-ruleset switching while exactly one ruleset
exists would build a mechanism nothing could test, which is the reasoning
recorded in docs/adr/0004. That reasoning does not expire, but the
condition it describes will eventually arrive: the regulations were amended
once already in 2025, and the program is young enough that another revision
is plausible.
What happens then:
- An ADR defining how a report names its ruleset, how a caller could pin or inspect one, and what happens when a document's vintage cannot be established (the answer will be: the tool measures against what it says it measured against, and prints it, exactly as now).
- The ruleset identifier moves from a constant into a registry, with the
existing ruleset as its first entry, and
tests/test_registry.pypins both. - Check identifiers stay stable across rulesets. A check that means something different under a later ruleset gets a new identifier, and the old one retires, per the changelog's breaking-change rule.
Until then there is nothing to build, and this roadmap does not pretend otherwise.
Status: landed, 22 August 2026.
Sections 1391 through 1394 and PUC 398.4 were read end to end and every
obligation addressed to a label's content was diffed against the catalog.
Five requirements had been passed over in silence and are now registered:
PCL031 marketing claim consistency, PCL032 promotional materials inclusion,
PCL033 single label for general customers, PCL034 grandfathered emissions
exclusion identified (all permanent), and PCL035 footnote secondary group
percentage (conditional, on the Track A trigger question). The method, the
items considered and left unregistered with reasons, and the one wording
change recorded rather than registered are in the completeness sweep section
of docs/sources.md. Registered checks went from 30 to 35; implemented is
unchanged at 18.
The sweep is repeatable: it runs again whenever the regulations amend, and
its record updates in docs/sources.md with the date of the reread either
way.
Status: partially started; individual items below.
Extraction is where the tool's claims are made, so refinements here change what the tool is entitled to say rather than how many things it finds.
Landed. Extraction joined pages from the start; fixtures now hold what was assumed: rows split across a page boundary reach the checks, a wrap whose second line falls on the next page reads in order without geometry (which is why cross-page reconstruction would be building nothing), and cells never span pages.
Landed. The basis sentence now carries two counts: images declared and
shapes painted, forms descended with the same depth cap and cycle guard as
the image count, and an enumeration failure printed as unknown rather than
as zero. docs/adr/0012 records the decision and its fences: the count
qualifies what an absence means, no check reads it, and there is no
threshold anywhere in the path, because a threshold would convert a
description into a judgment.
Refused, and recorded: docs/adr/0011. The short case: OCR output is a
model's guess at text, which would put a probabilistic layer inside a tool
whose value is that it contains none, and its errors land in both dangerous
directions while looking like honest readings. A scanned label fails closed
today, loudly, and a human renders the page and looks at it - which is the
resolution the calibration record itself used. The ADR also writes down the
bar anything that reopens this would have to clear.
Landed, in CONTRIBUTING: upgrades are their own commits, one dependency one
commit, the gate reruns, and the changelog records even a floor move. The
policy names the specific hazard - geometry.py reads pypdf's layout
machinery from below its public surface, so a bump can silently take column
reconstruction away, which is safe but quiet, and there is a test for it.
Status: landed.
The JSON report is consumed by things other than humans the moment anyone scripts this tool, so its shape is a public interface and should be treated like the check identifiers already are.
- Schema versioning. Landed. Every report carries
schema_version, starting at 1; keys are append only within a version, and the policy is recorded indocs/adr/0010. - Contract tests. Landed. The exact key sets of the report, each document entry, each result, the summary and a catalog entry are pinned, so a shape change fails a test instead of a stranger's script.
- Machine-readable catalog. Already present before this track was
written down:
catalog --jsonemits the sameCheckSpecdicts the reports carry. - Exit-code contract. Landed. The precedence combinations are pinned explicitly, including the shadowing that holds today: because twelve registered checks always report as not evaluated, code 2 beats code 1 on every run over a readable document and exit 1 is unreachable until a conditional check implements. Holding that in tests means implementing a conditional check surfaces deliberately rather than silently.
Status: not started. No blocker.
Installation today is clone-plus-uv, which suits auditors and excludes everyone else. Publishing to PyPI widens the audience without changing the artifact: same wheel hatchling already builds, same offline guarantee, since the tool gains no network behavior from being installable.
- Trusted publishing from the existing release workflow, which already verifies the tag, reruns the gate at the tagged commit, and separates read authority from write authority. PyPI publishing is one more constrained job behind the same gate, not a new pipeline.
- Build provenance attestation on the published artifacts.
- A
--versionflag, which a packaged CLI needs and a git checkout never did. - Python 3.14 support. Landed: the full gate passes on 3.14 with the locked dependency set unchanged, so the CI matrix and the classifiers now declare it.
- Keep the README's install section honest in both worlds: uv-from-git remains the auditable path, PyPI the convenient one.
What remains in this track is the one step this project cannot do from inside the repository: registering the project on PyPI and configuring its trusted publisher, which is an owner action on pypi.org. The workflow job that publishes behind the existing release gate follows that, not before.
Status: landed, with one number still moving.
- Raise the coverage floor. Landed at 96, where the suite measures a little over 97. The floor moves only when what it excludes has been argued with; the next raise is deliberate work, not housekeeping.
- Property tests for the invariants the ADRs assert. Landed over generated inputs: normalisation is idempotent and folds only its declared classes; space-insensitive matching finds a phrase under any spacing and never under an inserted word; cell reconstruction loses and duplicates nothing, so no grouping it produces can carry a claim about absence.
- A cached-label regression harness. Landed as
scripts/check_regressions.py: records fingerprints per cached document into an uncommitted baseline beside the cache, then proves the current tree concludes exactly what the recorded tree did. A contributor without the cache never notices it; with the cache, any change that moves a conclusion about a real label surfaces before pushing. - Malformed-PDF fuzzing. Landed: seeded deterministic mutations of valid label PDFs, asserting no mutation crashes extraction or yields a result set that does not account for every registered check.
Status: continuous.
- Keep this roadmap honest. When an item's blocking event arrives or an item is refused, update this file in the same commit as the work or the refusal. A stale roadmap misstates the project worse than none.
- Anticipated ADRs, so their numbers do not get assigned twice: 0010 (schema version, Track F), 0011 (OCR refusal, Track E3) and 0012 (painted shapes, Track E2) are taken; the 2026 trigger derivation takes 0013 when Track A lands, unless another decision records first; the second-ruleset ADR when triggered (Track C). Numbers go to whichever decision lands, and this list moves in the same commit.
- Auditor-facing documentation: a short document aimed at someone
verifying this tool rather than using it - how to reproduce a finding
by hand, how to audit the catalog against the sources, what the tool
refuses to conclude and why. Much of it exists across the ADRs and
docs/sources.md; it wants collecting under one entry point. - Outreach, with the affiliation line carried intact. The tool is useful to researchers and to suppliers checking documents they were issued. Any sharing of it repeats, verbatim, the disclaimer that this project is not affiliated with or endorsed by the Energy Commission, and never summarizes findings about named suppliers.
| When | What | Track |
|---|---|---|
| Now | Distribution (owner action pending) | G |
| Now | Extraction honesty items that need no external event | E1, E2, E3, E4 |
| After 2026-10-01, as data year 2025 labels publish | Trigger ADR, then PCL023 and PCL024; new-vintage calibration begins; watch for anything that unblocks PCL029 | A, B |
| On any regulation amendment | Second-ruleset ADR and registry | C |
| Continuously | Calibration batches toward ninety one; roadmap and ADR upkeep | B, I |
Tracks F, G and H are ordered before Track A's implementation phase on purpose: the contract tests, the fingerprint harness and the widened calibration set all make the moment the trigger checks land safer, and none of them is waiting on anything.
Recorded so they stay decided. Each has been considered; each is refused for a reason that still holds; reopening one means overturning its reason, not resubmitting the idea.
- Rankings, scorecards, leaderboards. The tool reports which prescribed elements a document carries. Aggregating that into comparative judgment about named suppliers is the thing the README disclaims in its opening paragraphs.
- Reading the spreadsheet published beside each label. Decided in
docs/adr/0009: it is an alternative rendering, not the label, and checking it produces five misleading deviations out of seven. - Bulk fetching of published labels.
fetch_examples.pyrate limits and refuses bulk runs. Widening happens in deliberate, diverse batches, because the point of the set is judgment, not coverage statistics. - Positional attribution of contact details. Settled in
docs/adr/0007; nearness is not ownership, and no published source supplies a threshold. Column geometry (ADR 0008) took the one use of position that was defensible and fenced it. - Summing fuel mix columns against the displayed total. PCL025's reason: whole percentages with no published rounding rule mean correctly rounded arithmetic fails an equality test, and any tolerance would be invented. The statewide column the Energy Commission itself supplies is off its displayed total on all twenty four labels read.
- Any large language model anywhere in the pipeline. Determinism is the product. Also settled empirically by the standards table: there is no AI surface to evaluate.