Method · Changelog · 4 Sept

Every change, in the words it was made with

This is the repository’s own history, newest first, each entry the message its change was committed with and nothing added after the fact. The messages say what changed, why, and what they found wrong on the way, including in earlier entries. A figure quoted in one is the figure on the day it was written. A day to a page.

746 changes over 22 days since Monday 10 August 2026 · as held in the repository at 10 Sept 2026, 09:03 pm AWST, when this build was deployed. The defects that reached the database have their own register on the data-quality page.

Friday 4 September 2026 · 22 changes

The record for the rebuild-window collision, which the previous commit's script skipped

08:58 pm AWST · b0be893

Its Python stopped at an OPERATIONS anchor the earlier deadlock rewrite had already replaced, so the code shipped and the two doc notes did not. This is the ops addendum and the DATA-QUALITY 53 coda.

A freshness read waits two seconds for a board being rebuilt, and says so

08:58 pm AWST · 081e18e

The first scheduled depth-boards run under the up-front lock succeeded: 1,166 seconds, seventeen locks taken at the door, only transient readers waiting, nothing stuck. And the deep chain, whose crawl ran until 12:38, put check-integrity inside that window. Its freshness check reads board_status(), waited on aged_board until the authenticator's eight-second lock_timeout cancelled it, and threw before filing --- so the chain reported "a step failed" and left no row to say why. health.ts was

A locked board is, by definition, not stale. board_status() now carries a two-second lock_timeout --- a function-level lock_timeout, unlike statement_timeout, is consulted at each lock wait and so actually takes effect, proved by reading the setting back inside a function body --- and both suites treat the timeout as "rebuilding": check-integrity files its verdict with a warning, health prints boards=rebuilding, and neither can turn a rebuild into a red chain or a false ok again.

The six tables the previous commit said it changed, and did not

08:34 pm AWST · 0ceea75

ae4a23c's message claims seven tables gained a scrolling region; its script stopped at the dealer page's first table, whose wrapper carries an extra class the anchor did not allow for, and the chain committed anyway with only the repricing panel's two changed. This is the other six --- the dealer page's three and the model page's three --- and the record is corrected here rather than rewritten there.

Seven more tables scroll in a named region a keyboard can reach

08:31 pm AWST · ae4a23c

The repricing panel's two, the dealer page's three and the model page's with a visible focus ring and carries the table's name, and the ones labelled by a string gain a caption stating what they count. That leaves the tables on /coverage, /value, the yard and the reports.

Three more tables carry a caption and scroll in a named region

08:22 pm AWST · 3336b26

The home Most traded table, /moving's model table and /market's fuel scrolling region that is a tab stop with a visible focus ring, as the kit's table already models. Nothing visible changes.

A wide table is a region a keyboard can reach

08:20 pm AWST · bda5593

The kit's table scrolls inside a named region that is a tab stop with a visible focus ring --- ink on yellow, yellow elsewhere --- so a keyboard user can reach and scroll it. The other tables still owe this; the kit models it.

The heat table's qualifiers are its caption

08:18 pm AWST · 1c41c04

Unit, n, date and basis are part of the table to a screen reader now, not a paragraph above it, and the [u] shorthand is met before the first cell that uses it (a11y-dense-ui 5.2). The kit's table models the rule the other twenty-six tables still owe.

H1 is the state table that already exists

08:17 pm AWST · ea3b2e2

The stock cross-section eval-heatmaps asked for --- distinct cars live per state on a stated day --- is the per-state table /market has carried since market_state_board counted cars, dated by its board. Recording that it is served rather than building it a second time under a new name.

The one heat form the evidence allows: a matrix of printed numbers

08:15 pm AWST · ddbe12f

eval-heatmaps refused the colour ramp and the map permanently --- over the recorded state days a cell's watched listings moved 95.7% on average against 8.5% in its median ask, so any colour on a per-state change is eleven parts crawler and one part market --- and named the duration cross-section as the panel that survives, because a duration is a property of the car, not of the panel.

turn_matrix serves it: median days advertised at exit by state and segment over a stated closed window, used cars only (demo medians run three to five times longer and a blend describes neither), proved only, one row per car, every cell with its count. A cell under the floor of thirty comes back with its count and no median, so the table prints [u] and the number rather than a blank: the reader learns the cell is thin, not that it is empty. Over the last closed week 26 cells clear the floor, from 21 days for NSW utes to 41 for QLD utes.

The kit's HeatTable carries the magnitude in the digits and the emphasis in weight alone --- a row's extremes bold --- with the shorthand explained above the table where a reader meets it first, rows and columns scoped, the table named by its heading. Tested on the rendered markup, including that nothing in it is coloured.

The dealer's age bands and exit dots draw from the kit

08:09 pm AWST · e7a74f3

Two more forms. Categorical columns place a continuous marker on the band scale, because a 175-day median on a 0-to-180 axis lands inside the 180+ bar --- the page had learned that once and the kit now proves it. And a dot strip: one mark per car along one axis, staggered so coincident days stay visible, hollow where the car was discounted first, the median marked --- which is also the strip the market wrap needs. Both are tested on the numbers and on the rendered markup, and every band carries the readout the interaction layer reads aloud.

The checks that run, and what each one proves

07:49 pm AWST · 870c4cc

Eleven scripts, one table: which invariant each holds, where it runs, and that every lint carries a planted failure it must catch before it is believed.

The listing's price step draws from the kit

07:47 pm AWST · 3b52fbd

The one chart CHARTS-PLAN rejected by name --- a line through every priced observation on an axis that is our fetch schedule --- had already become a step; it is the kit's step now, on a form that takes the real moments of each reading rather than slot indices, because a price held between reads is a fact about the price and the gaps between reads are a fact about us. Every reading is a mark, the change sits at the first reading that saw it, and the page says the read is live.

The yard's three comparisons draw from the kit

07:44 pm AWST · 36cd933

CutRates and PriceGap were the same chart twice --- two bars per band, the yard beside the field, a floor of thirty cars a side, a round axis, the count under each pair --- and ShelfCurve was two survival curves on one scale with a marker at ninety days. They are two kit forms now, pairedBars and curvePair, with the honesty rules in the geometry: a band under the floor on either side is dropped AND NAMED with its counts, never drawn thin; the axis top is the next round ten so the same measure reads on the same rule across both charts; a gap on one side breaks only that side's line; and the subject is identified by position and by the word beside the swatch as well as by colour, so a greyscale reader still finds it. Each form is tested on its numbers and on its rendered markup, and every mark still carries the readout n the interaction layer reads aloud.

Nothing a reader sees changes except that the dropped bands are now named under the chart instead of silently absent.

The definition of confirmed sold is a link, not a tooltip

07:38 pm AWST · f09c852

Five sentences sat in a title attribute on the header counter: hoverable with a mouse and reachable by nothing else --- no touch screen, no keyboard, and most screen readers skip it. The words now link to the definition's home on /method, which gained the #sold anchor.

The a11y row says what is enforced and what is still open

07:37 pm AWST · f010e9c

Tables named and scoped, contrast computed from the tokens, chart names checklist is listed as open rather than left as "the sweep".

One chart kit, two renderers, and the rules run instead of being intended

07:35 pm AWST · 0cc8b70

Section 5 said the same forms were drawn twice --- inline SVG on the site, react-pdf on paper --- with colours retyped in each, and that the chart rules were aspirations because nothing ran them. Both are addressed.

src/lib/chart-kit/: tokens.ts is the only place a chart colour is written (the PDF's #ffd200 is gone; print yellow is BRAND's #e4e619); geometry.ts computes every form as numbers with no renderer in it; provenance.ts makes a chart without a headline, a formal title, a unit, an n or its reason, a date or its reason, a basis or null, and a source a type error, and turns the spec into the two title lines and the accessible description both surfaces use; web.tsx and pdf.tsx draw the same geometry. The statement's forms on both surfaces are on the kit, and the proof sheet renders on the real renderer.

The rules are tests now. A null slot splits a series into runs, so no segment can ever cross a gap. A running month ends at the last observed day and only its last point is provisional --- it used to draw the unelapsed month as a dashed flat line under a footer reading "nothing projected or modelled". Small multiples share a scale and the flattened panel is the finding. Ticks come from a scale function, so a zero-spread cohort prints one tick, not three identical ones. And a hollow bar --- the mark that says "this day absorbs days nobody looked" --- is decided from the days the yard was actually crawled, which the statement payload now names (crawled_days), never from two zeros in the values: that heuristic hollowed a quiet day and drew the true backlog solid, bold, as the peak. check-charts.ts fails CI on any role="img" chart with no name, proved on a planted one; the whole suite runs in CI.

Two corrections travel with it. The Grand Prix "repair" from earlier today was not one: the box's first pass on the new URL failed the same way, and the run history shows it reaches that host about once in thirteen attempts a day on either address, while a DNS census puts 510 of 999 dealer hosts on the same Vercel infrastructure crawling normally. The cause was unknowable because every connection-stage failure reached the crawler as the string "TypeError: fetch failed" and the run rows kept the string; the crawler records undici's cause now, code first, so the next failure names itself. DATA-QUALITY 52 and the source's own notes say so.

The register is published as written, and the series can be cited

07:16 pm AWST · 49e207b

Two of section 8's deliverables. Every persona in the audit asked for provenance, and the cheapest credibility in the program is publishing the fifty-three defects we found rather than claiming accuracy.

/method/data-quality renders docs/DATA-QUALITY.md itself --- not a summary of it --- through a renderer that parses exactly the six constructs the document uses and treats every other line as a paragraph, which is the honest failure: nothing is interpreted the author did not write, and React escapes every string so no markup is ever injected. A planted document with every construct and the real file both pass scripts/test-md-lite.ts; the test asserts the page shows every entry the file holds, because a register showing fewer defects than we wrote is the least true thing the site could do. The document lives outside the uploaded directory, so it travels into the build the way the sha does: written by the stamp step, committed empty, discarded after a deploy. An unstamped build says so instead of rendering an empty register.

/api/v1/series serves the daily rollups --- market, state, fuel --- as JSON or CSV. Public, unlike /api/v1/value, because a series exists to be cited and a door only the crawler's email opens is not that. Three things it says rather than assumes: a day with no row is a gap and is listed as one, never interpolated (2026-08-12 and 2026-08-23 today, 2026-09-03 once the next day is written); a break carries the register's own words for the window it falls in; and every column is named for its unit, with the columns it DROPS named too --- daily_market_rollup.sold counts advertisements at 228% of the car count and is not served. Read one scope at a time, keyed on the day, because nine states share every day and a page boundary on a non-unique key drops rows silently. Verified on the built app: 21 market rows with both gaps and the 29 August break; a WA window with its gap; fuel at exactly seven scopes by nineteen days; malformed input refused; CSV gap rows present with empty cells and gap=1.

The status table catches up with the tables under it

07:05 pm AWST · 7ba8221

Section 15 said five section-4 rows were remaining that section 8 had marked done on 3 September, said 4.6 was on a path to its own timeout when 0202 and 0229 had already moved every sold figure onto sold_car_fact (288 runs in 24h, none failed, mean 6s), and said section 2 still owed the reports and the rollups when e547a55 closed both. A status table that lags its own evidence is the least useful kind of record.

Open question 3 is recorded as answered by 0283, with the distinction the question did not draw: a cross-car diff is PROVED not to be a price change, a flap is a real reading of a page that oscillated, and the two populations are near-disjoint. Proven-false events are excluded everywhere; flapping stays in with the detector. Whether flapping should also go is left as Taj's call, explicitly. Question 5 is resolved: f109df0 has been on main since section 0 and the footer stamp names the live commit.

Two dead sources, one dead domain, and the deadlock underneath the boards

07:01 pm AWST · 4fdbcc3

Health named dealer:berwickldv. The class query --- enabled sources whose last five runs all fetched nothing with a fetch error --- named a second, dealer:grandprixautogroup, still inside the alarm's two-day window. Same symptom on the box; two facts.

berwickldv.com.au is NXDOMAIN from 1.1.1.1 and 8.8.8.8; none of its 36 VINs appear at the seven Berwick siblings. Withdrawn with the evidence in robots_notes, and its 36 live rows LAPSED, not delisted: the crawl's own rule that a failed fetch proves nothing about a car is right and was left alone, which is exactly why those rows would otherwise have shown as live stock forever. A dead domain is evidence about the site (0208). recordLapse is exported so the script writes the crawl's own two rows, not a copy.

Grand Prix rebuilt onto Vercel. Its index sat on an apex the box cannot reach while it reads www fine and all 28 live rows already carry www; the index moved to the URL the apex itself redirects to. One config row. The box's next pass is the proof and DATA-QUALITY 52 says so.

partner was never a page reading two boards or another job --- both were checked and neither exists. It is board_status(), called by every /market and model-page render, walking all thirty-three boards alphabetically in one transaction, while the job truncated two of its pairs in the reverse order. The job now locks all seventeen boards it rebuilds first, in the reader's order, so it never waits while holding; a guard recomputes what the job's functions truncate and demands the lock list match, and was proved to fire on a one-board list (sixteen missing).

And the day the deadlock cost: 2026-09-03 has no daily rollup, because the rollup found a stale snapshot after the job that refreshes it died, and correctly refused. It cannot be written after the fact --- a day's stock columns can only describe that day's snapshot --- so it is a gap and stays one. daily-rollup now refreshes the snapshot itself when it finds one over 90 minutes old, so one job's failure is no longer another job's lost day.

The one integrity failure is that rollup stamp, 46 hours old against 36; it clears at 12:50 UTC when the first run of the new daily-rollup writes 4 September. Everything else passes, 33 checks.

The ticker's proved count is named for what it is

06:53 pm AWST · bcf09c3

`turn.delisted` in the model ticker carried the proved-only count --- the seller's own SOLD text or a dead page --- and was printed directly under the word "Confirmed". The copy was right and the name contradicted it. Across all 1,439 model boards the two quantities differ by 6,106 cars, 19.6% unproved, so the name was one edit from making the copy wrong with no line of it looking stale: anyone repointing `delisted` at a genuine exit total would have been making the name honest and the heading false.

It is `turn.proved` now, end to end: the interface, both mapping branches (the board's `proved` key, and the tape fallback, which is proved-only because model_sold_tape filters exit_basis), and the three JSX readers. Nothing rendered changes.

One related inconsistency from the same audit is closed with it. The page body says "(most recent only)" when its figures came from the displayed tape rather than every car; the page's metadata did not, so a search snippet would have called a 60-row cap the model's total. Unreachable today --- every board carries a full turn key --- and now impossible.

The deadlock that empties the boards, and how to get them back

08:14 am AWST · 2d8662b

Recovering the boards after 2026-09-03's failure meant learning this twice in one hour, so it is written down rather than rediscovered.

The refresh functions truncate each board before refilling it, and pg_cron sends a whole multi-statement command as ONE transaction, so every AccessExclusiveLock is held until the entire job commits. A page holding one board and wanting another closes the cycle. Twice on aged_board against live_snapshot, once on model_chart_board against model_event_board, and once more before that: six of depth-boards' last thirty-five runs have failed.

Recovery is not obvious and cost real time. There is no board-refresh script, because cron is the mechanism; a plain call from an MCP session dies when the connector drops the connection. A one-shot cron job runs detached and works, provided it is unscheduled only after cron.job_run_details shows it finished, because cron.unschedule kills a running job.

And retry first. refresh_model_chart_board deadlocked at 21 seconds and then rebuilt cleanly in 28 on the next attempt, which also confirms it survives the price-change patch it had not been run under. All 31 checks now pass.

A figure says which cars it counted

07:18 am AWST · e547a55

Three numbers described one population. The home page's sale_speed said 16,768 used and demo cars watched out of the market, market_by_fuel summed to 16,584, and sold_car_fact --- the table every other sale surface reads --- held 15,854. The first two were each built from their own copy of a query on listing_current, and that view carries no exit basis, so both counted soft 404s as sales; both also filtered `days is not null` BEFORE taking one row per car, so 1,044 cars carried an age from an older advertisement of themselves, which is the duration of a different listing episode than the exit being described.

Both now read sold_car_fact. Verified from one snapshot: sum(sold) from market_by_fuel() and sales from sale_speed() both returned 15,913, gap exactly 0. The median survives at 37; p25 moves 14 to 13. What changes is that the panel says what the 15,854 are --- "used and demo cars watched out of the market, 85% of them proved sold, and those in 35 days" --- instead of calling them sales.

Then the same defect, everywhere else it had spread:

- turn_by_model has never had an exit_basis filter, and the home page called its output "confirmed sales". 2,301 of 15,529 (14.8%) unproved. Aggregated that is mild, 37 days against 35; it does not distribute evenly. Of 334 rows, 6 are majority unproved and 3 have no proof at all --- Toyota Prado 8 exits 0 proved, Landcruiser 70 Series 18 and 0, Mercedes-Benz GLC 4 and 0. A page-level share cannot fix that: 85% proved overall is exactly what hides a row that is 0% proved. Both that table and /moving's now carry a per-row proved count.

- generate_market_report_data has no filter either, and every archived report headed a panel "Cars confirmed sold". For the week of 24 August that named 10,723 cars over a population 2,530 of 10,213 unproved: 24.8%, worse than the live surfaces because a report's window closes before later proof arrives. ReportView already had the honest wording for the running week, gated on proved_total --- a key no stored payload carried, so the 0279 correction reached the preview and left the archive alone. The generator now emits the split, the four issued reports have it backfilled, and the wording is fixed at all five sites including shareText, which is the one artefact built to travel without footnotes.

- /market's fuel table announced itself to screen readers as "Models by cars confirmed sold". Wrong about the rows, which are fuels, and wrong about the population. That was mine, from b876481 yesterday, whose own rule was that a table with a heading in reach gets aria-labelledby so the name cannot drift; this table has one and was misclassified. It is labelled by its heading now, and the page publishes a proved share for the first time --- "proved" and "unproved" appeared nowhere on /market while its metadata promised confirmed sales.

Two integrity checks, so neither can drift back. A tolerance check was measured and refused: 855 of the 15,854 exit in a typical 24 hours, so a band loose enough not to fire on a twelve-hour-old board is far too loose to notice the population changing. The guard is definitional instead --- both readings must come from one table --- and it was verified to fire by pointing its predicates at a function with the old shape.

Also recorded, because both cost real time today:

- `SET statement_timeout` in a function's definition does nothing for that function's own call. Same function, minutes apart: plain call cancelled by 57014 with the SET in place; `set statement_timeout = '15min';` as a separate statement first ran three minutes and finished. The timer is armed when the top-level statement begins and nothing re-arms it. The cron prefix is load-bearing, not decoration.

- Migrations must cite each other by NAME. The number is assigned by sync-migrations from the order the database applied it, long after the prose is written, and today three migrations guessed their own number and got it wrong. Those three cannot be fixed --- the file is generated from the stored statements and must stay content-exact --- so the mapping is recorded instead. Five such references in .ts files were editable and were corrected.