All notable changes to perimeter are documented here. Format: Keep a Changelog; this project adheres to Semantic Versioning.
tools/a11y.mjsread only axe'sviolationsbucket. axe returns four:passes,violations,inapplicable, andincomplete, the rules it ran and could not decide. Anything undecided was invisible to the gate, which printedok <page>and a closingN page(s) clean against 6 rule sets, and exited 0. Measured 2026-08-18 against a page carrying a focusable link insidearia-hidden="true", a real 4.1.2 problem that axe files as undecided in jsdom: the gate reported the page as clean and exited 0. Same for an<iframe>axe could not reach into, whose contents were therefore never checked at all. This is the repository's own subject, "could not determine" rendered as a recorded value, in the check that backs its accessibility claim. An undecided rule now fails the gate unless it is declared inUNDECIDABLE_HEREwith the reason and with where the rule's subject is checked instead.- What was not decided is now printed on every run, passing or failing. The gate's only output format said "clean against 6 rule sets" while three rules per page were undecided and named nowhere. Each page's line now carries its undecided count and rule ids, and the run ends with every declared rule, why it cannot be decided here, and where it is covered. A declared rule axe never reports is marked as such, so a declaration that has stopped matching anything does not sit in the file looking like coverage.
- The gate had no failure evidence at all, while
tools/determinism.shhad thirteen cases and ADR 0004 requires it.tests/test_a11y_gate.pyruns it against fourteen inputs: no argument, a missing directory, a directory with no pages, an image with no alt text, a page with nolang, one bad page among good ones, a focusable link insidearia-hidden, an untested frame, and the disclosure a clean run owes. make verifyrunsnode-syncbeforetest. The new gate tests neednode_modulespresent, and without this they would skip, and a skipped gate test reading as a passing one being the same failure. They refuse to skip whenCIis set. Make builds each target once per invocation, sopagesnamingnode-synctoo costs nothing.- README's Accessibility conformance row said "two axe rules suppressed for want of a
renderer", which was wrong in both halves. Measured: three rules come back undecided
on all three pages (
color-contrast,landmark-one-main,page-has-heading-one), andtarget-size, named as one of the two, appears in neitherviolationsnorincompleteon any page. It was never suppressed; it never fires. Two of the three are decided from the markup intests/test_pages_html.py, so the substantive gap is one rule, not two.
make syncinstalls withuv sync --locked.make lock-checkstill runs first insidemake verifyand is unchanged, butmake syncis also documented as an entry point on its own, and run that way--frozenhad no drift gate in front of it.--lockedmakes the same comparisonuv lock --checkmakes and exits 1 the same way.- The Standards Conformance table's verdicts were in a form the portfolio's
conformance reader rejects. That reader takes a verdict only when the character
straight after
Appliesis whitespace,:,(,-or an em dash, soApplies.,Applies, not met.,Applies, Tier C.,Applies, L1.andNot applicable.all read as invalid, and fifteen accurately written rows scored as one broken cell. The verdicts are nowApplies:,Applies (not met).,Applies (Tier C).,Applies (L1).andN/A (...). Not one row's meaning changed, and no gap changed state.tests/test_standards_conformance.pyenforces the new grammar and says why the punctuation is load-bearing.
- The paging walk stepped its offset by the page it asked for, not the page it got.
resultOffsetmeans "skip this many records", so a layer that caps a page belowPAGE_SIZEand setsexceededTransferLimitleaves a block of records between the end of the page and the next offset. Stepping byPAGE_SIZEwalked over that block and the walk still ended normally: against a layer holding 5,000 records and capping pages at 1,000, the acquisition collected 3,000 and reported success. The published counts would have described three fifths of a layer as the whole of it, with a hash and a retrieval date beside them. Both layers publishmaxRecordCount2000 today, which is why the acquisition on record is complete: the layers' ownreturnCountOnlytotals, read 2026-08-16, are 23,334 and 132,522, exactly the counts insources.py. The walk now advances by the length of the page it was handed. - An acquisition is checked against the layer's own record total before anything is
written.
layer_record_countasks the layer how many records match the same predicate the walk uses, andacquirerefuses, writing no file, when the two disagree. Short reads of any cause now stop instead of arriving as a smaller dataset. - Schema drift is checked on every row, not on the first one. The check read
rows[0]alone, so it could not fail on any file whose first row was intact: a column that disappeared further down was classified as an empty cell for every later record and published as a field nobody filled in. Measured at DINS scale the per-row check costs nothing detectable, 4.56s against 4.58s over 132,522 rows. - The published artifacts must have measured every record they say was acquired.
recordsandsource.acquired_record_countare two counts of the same file taken at two different moments, and nothing compared them. tests/test_acquire.pyenforces its own claim that the real endpoints are never contacted. It was a convention; an autouse fixture now fails any test that reaches for a socket.
- The determinism check is a script with its own tests. The inlined version could not
fail: a workflow
run:block usesbash -ewithoutpipefail, sofindon a missing directory exited 1 into a pipeline that reported success, and with no files to hash both runs came out identical, so an empty build passed. Both runs also went into the same directory.tools/determinism.shcompares two trees, refuses an empty or missing one, andtests/test_determinism_gate.pyruns it against thirteen cases that should fail it. make lock-checkis the lockfile-drift gate, runninguv lock --check.uv sync --frozenwas doing that job and cannot: measured 2026-08-15 against apyproject.tomlthis lockfile does not satisfy,uv lock --checkexits 1,uv sync --lockedexits 1,uv sync --frozenexits 0. Every target after it runsuv run, which rewritesuv.lock, so the old arrangement went green and then repaired what it was checking.uv sync --frozenstays as the install.- The coverage floor no longer omits the module that touches the network.
src/perimeter/acquire.pywas excluded, which moved the reported figure from 88% to 100% and let the 90% floor pass over the one module carrying this project's promises about somebody else's server.tests/test_acquire.pyexercises those promises offline withurlopensubstituted: HTTPS only, the honest User-Agent, stopping on 401, 403 and 429 rather than working around them, refusing a non-JSON challenge page, refusing an ArcGIS error payload, the pause between pages, and the paging walk. Nothing is omitted and the tree is genuinely at 100%. ci.ymlno longer calls its gates merge-blocking.mainhas no ruleset and no branch protection, soverify,secret-scanandsastreport and block nothing. The header says that..github/rulesets/main.jsoncarries theprotect-mainprofile that would make the old sentence true, committed and deliberately not applied.
- zizmor and CodeQL. Nothing in this repository read
.github/workflows/until now, which is why five dependabot pull requests that change nothing butpages.ymlcarry green checks from jobs that never open the file. zizmor runs online, because its offline default silently skips the audits that need the API. CodeQL covers actions, python and javascript-typescript, and fails when there is no SARIF to read as well as when there are findings. docs/adr/, which had existed and been empty since the scaffold while the decisions it should hold were argued in prose. Back-filled: the three-state cell model, counting an out-of-domain value rather than raising on it, and the published-versus-inferred marker basis. Plus a new one, ADR-0004, on what it takes to adopt a gate here.- A Standards Conformance table in
README.mdand a.standards-versionpin, read bytests/test_standards_conformance.pyso neither is decoration. The table records the gaps as gaps, and states that the standards program's applicability manifest has no entry for this repository, so the scoping is this table's reading rather than the registry's. .github/CODEOWNERS, routing workflows, the ruleset directory, dependabot config and the two reviewed registries.
docs/MARKERS.md, the marker audit. Every value this project treats as a missing-data marker rather than as a recorded value, with its evidence, the document it can be checked against, its effect on the published figures and a confidence. Two further published sources are now cited alongside the layers' coded-value domains: FRAP's Wildland Fire Perimeters metadata document and CAL FIRE's DINS database dictionary, both linked from the dataset pages and both recorded inPROVENANCE.md.- Every field carries the basis of its markers.
FieldSpec.basisispublishedwhen every declared value appears in a published domain or definition, andinferredwhen at least one was read off the acquired file. It is inschema.py, in the JSON artifacts asmarker_basis, and on the pages beside each field's marker list. Twelve of the twenty-seven fields that declare a vocabulary are published; fifteen are inferred. The module docstring previously said nothing in the file was inferred, which was not true of the free-text fields, and is corrected. marker_counterfactualsin the perimeter artifact. The local-incident-number duplicate key, recomputed with the all-zeros placeholder read as a number, so the cost of that judgment call is a counted number rather than a claim in prose.- HTML conformance and WCAG gates in CI.
make verifynow ends inmake pages:html-validatefor HTML conformance and markup-level accessibility, andaxe-corein a headless jsdom for the WCAG 2.0, 2.1 and 2.2 A and AA rule sets. Nothing is served and nothing is deployed; both read files off disk.tests/test_pages_html.pycovers the same structural ground from Python with no toolchain beyond it, measures contrast arithmetically over both palettes, and asserts that every number a page prints traces to the pipeline output.
UTILITYMISCSTRUCTUREDISTANCE:NAandN/Aare now one finding. CAL FIRE publishes Not Applicable for this field spelledNA, and for the propane-tank field on the same form spelledN/A. In the acquired file the two spellings never share an incident and fall on opposite sides of the 2020 incidents, recorded distances appear on both sides of that line, and both spellings sit beside a propane Not Applicable at the same rate. Both are now counted as the published finding. Recorded value moves from 31,239 (23.6%) to 37,783 (28.5%), recorded as unknown from 6,544 to 0, andoutside_published_domainfrom 243 to 6,787, since the domain published today does not carry theN/Aspelling.SITEADDRESS: two placeholder addresses are no longer counted as addresses. The marker net matches single tokens, soNo Address Available(513 records) and a composed placeholder readingNULL NULL UNKNOWN CA 00000(160) were passing through it as recorded addresses. Recorded value moves from 124,811 (94.2%) to 124,138 (93.7%).- The all-zeros local incident number is endorsed, and relabelled. The treatment is
unchanged and no figure moves. Its basis is corrected from published to inferred: FRAP
publishes no domain for
INC_NUM, and the reading rests on the field's published definition, "Number assigned by the Emergency Command Center of the responsible agency for the fire", together with the distribution of the file. - Pages gained a
mainlandmark and a skip link, a caption and scoped headers on every table with a row header on every row, and the heading level the index page was skipping. The light-theme green moved from#1baf7ato#189a6bso the three state colours clear 3:1 against both surfaces. irwin_present_tenths_pctper year anddamage_values_tenths_pctare now published in the JSON artifacts, so every share a page prints is a share the pipeline published.
- Three-state cell model (
cells.py,schema.py). Every measured cell is counted as a recorded value, a recorded unknown, or an empty cell, and the three are never collapsed. A non-presentCellwill not hand over a value, so no rendering layer can read a blank as a zero. Coverage is a first-class output: all three counts are always published, and a share over an empty denominator is published as absent rather than as a number. - Fail-closed drift refusal. A measured column going missing raises
SchemaDriftError. A cell holding something that reads like a missing-data marker, in a field that has not declared that exact marker, raisesSentinelDriftError. The reviewed registry declares markers per field, because the same word means different things in different columns:Noneis a published street-type finding andNonein a parcel APN is not. - Measurement one: FRAP historical fire perimeter completeness. 23,334 records across fire years 1878 to 2025 from version firep25_1. Records per year with IRWIN ID coverage inside each year (3,633 records carry one, 15.6%), per-field three-state counts, records per decade against the 10/50/300 acre figures in FRAP's published collection criteria, and counts of records sharing an identifier, published as candidates rather than as duplicates.
- Measurement two: DINS damage inspection coverage. 132,522 structure records across
451 incidents. Per-field and per-incident completeness, the damage vocabulary reported
with
No DamageandInaccessiblekept distinct from blanks, and field completeness split between assessed records and the 591 recorded asInaccessible, which is the measurement that separates "not inspected" from "inspected, field blank". - Deterministic artifacts.
site/data/*.jsonand the three pages are byte-identical across re-runs on the same inputs. No wall clock in any payload, integer arithmetic for every published share, sorted keys throughout. - Acquisition, kept separate from the build (
acquire.py). Reads the GeoServices endpoints listed among each dataset's own published resources, geometry excluded, with a User-Agent naming the project. RaisesAcquisitionBlockedand stops if an endpoint declines; there is no fallback path and nothing retries under another identity. The build never touches the network. - Hand-written fixtures, not sampled from the acquired files, because DINS records carry real addresses and parcel numbers. Each fixture value exercises one classification case: a genuine zero, an empty cell, a published unknown code, a marker word, a finding of absence, a value outside the published domain, a structure that could not be reached, and a record with no year.
PROVENANCE.mdwith per-source endpoint, licence, version, retrieval date, record count, byte count and SHA-256, the publishers' own caveats quoted, and an explicit list of what is excluded and why.tests/test_provenance.pyfails if it drifts fromsources.py.
Observations from the retrieved files, recorded because they shape how the counts read. Neither is a defect report and neither is presented as one on the pages.
- FRAP's
CAUSEandC_METHODhave no empty cells at all, and carry the publishedUnknowncode on 10,514 and 15,081 records respectively. FRAP's firep25_1 release note records editing 12,097 collection methods from null to Unknown, which is exactly the kind of move a two-state count would hide. - An all-zeros local incident number appears on 12,469 perimeter records across many units
and decades. It is counted apart from recorded numbers; reading it as an identifier would
have made thousands of unrelated fires appear to share one incident. FRAP publishes no
domain for that field, so the reading is an inference;
docs/MARKERS.mdsets out the evidence and counts the other reading beside it. - Several DINS free-text fields carry marker words where a value would go,
CITYmost often. Each is declared per field with a note, so they are counted as markers rather than read as place names. - Some DINS coded fields carry values outside their published domain,
ROOFCONSTRUCTIONandEXTERIORSIDINGmost often. These are counted and published asoutside_published_domainrather than raising, because they are ordinary values and crashing on them would hide them.