Method · Changelog · 13 Aug

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.

Thursday 13 August 2026 · 84 changes

Health check: the hygiene counts stop scanning the corpus

11:45 pm AWST · a54eadd

The 23:36 monitor pass printed its summary line and then failed:

ALERT health check failed: count of listing_observation could not run

That is the script working. It throws rather than reporting a zero it could not observe — the behaviour added after it once printed live=0 over 152,036 live listings (DATA-QUALITY entry 16). But a monitor that alerts on its own weight trains you to skim past it, and the next alert will be a real one.

The failure is not the plain obs= count, which succeeded on the line above it. It is the two hygiene assertions further down: no present observation missing its source_view label, no present observation priced under $500 or over $2m. Both must return zero, both always have, and at 557,714 observations the cost of proving it was 2,732ms on an index scan and 3,499ms on a parallel seq scan over the entire table.

0080 and 0085 met this class by raising the ceiling, which is right when the query has real work to do. These two do not. The predicate is the violation, so a partial index over it holds only the rows that break the invariant — none, one empty page, 8KB each — and both counts become index-only scans that find nothing: 0.131ms and 0.158ms, one buffer, zero heap fetches.

The point is the shape, not the speedup. A partial index is only ever as big as the problem it looks for, so this stays flat as the corpus grows rather than needing a larger timeout every few weeks. Raising a ceiling buys time; this stops the clock.

Built with CREATE INDEX CONCURRENTLY against the live database — 19 crawl runs were inserting, and a plain CREATE INDEX would have blocked every one of them until the build finished.

Give CI the database its build has always needed

10:50 pm AWST · e4f565a

The web-build job has never passed. Forty runs, every one red, since the commit that added it — which makes it decoration rather than a check, and a red line that always means nothing is worse than no red line at all. It is the same failure every time:

Error: coverage counts failed: TypeError: fetch failed Export encountered an error on /page: /, exiting the build.

The placeholder credentials satisfied db()'s "must be set" throw and nothing past it. `/` is prerendered with revalidate 60, so building it calls coverage_counts() for real, and example.supabase.co does not answer.

The two ways to keep the build database-free are both worse. Making `/` dynamic drops the static render on the page this product is judged on. Letting the prerender fall back to placeholder numbers would ship a home page quoting a corpus that does not exist — and the throw is already the right behaviour in production, where a failed ISR regeneration leaves the last good page serving rather than replacing it with zeros.

So the build gets the secrets that already exist in this repository and that crawl.yml already uses, scoped to that one step; every test above it is a pure function and stays that way. The cost is a handful of RPCs per push.

One property worth naming rather than engineering around: the build now depends on the database being responsive, so a push during a heavy crawl can fail it. That is true of a production deploy too — this makes CI honest about it rather than immune to it.

Days to sell comes off the request path

10:36 pm AWST · 5341561

The two panels that carry the pitch were blank on the live site: "median days to sell" and "cut before selling", both from sale_speed(), which reads the seller-published `fields` of every delisted listing. Measured 5.6s against PostgREST's 8-second ceiling, on a page that fires ten queries at once while 24 crawl runs are writing. It did not fail every time — it failed whenever the instance was busy, which is when someone is most likely to be looking, and tolerant() then rendered "needs observed sales" over 1,939 confirmed sales. An absent number with a comfortable explanation is entry 8, and this was one.

Not the merge: a `status = 'delisted'` scan never touches a merged row, and the plan is unchanged — still an index scan on listing_status_idx. What changed is the corpus. From EXPLAIN, the 5.6s is 1.7s resolving one observation per delisted listing and 3.8s more resolving the deduped fields payload through the second lateral. Both are per-row TOAST reads and both grow with the delisted set. No index helps.

So it joins every other heavy aggregate on a board, refreshed by the 6-hourly job that already exists under a 15-minute ceiling. sale_speed() keeps its signature and reads the board: 6,703ms to 112ms, same numbers — 1,939 sales, 53 makes, median 32 days, middle half 10–72, 47% cut before selling. check_stale_boards() gains the new board, so a refresh that stops is a failed run rather than a panel claiming there are no sales.

market_pulse() is the same lesson one step down. It reads delisted_at and price only, but took them from listing_current, which always pays for the fields lateral because a LATERAL with LIMIT cannot be join-removed — the exact reason 0060 created listing_current_lean. Moving it to the lean view takes that scan from 2.2s to 1.56s for nothing but the right view name.

Verified on the live site: all four panels populated, 12/12 integrity checks pass, all boards fresh, tsc clean, and the tree rebuilds the database exactly.

A check that cannot run must not print ok

10:36 pm AWST · 94c8ecf

Found while answering "did we break anything" after the merge: health.ts reported live=0 over a corpus of 152,036 live listings. Nothing was broken — the exact-count scan was cancelled under crawl load, and `const { count: n } = await q` returned `n ?? 0`. The next two runs printed the real number.

Pulling that thread found the same bug somewhere it matters far more. check-integrity.ts was printing

ok no price equal to the listing's own identifier

for a check that had not run at all. check_price_equals_identifier() reads `fields` for every priced observation — 3.7 million buffer hits, 78 seconds measured — and had crossed the API role's 8-second ceiling as the corpus grew past half a million observations. The RPC returned an error, the caller destructured only `data`, and null counted as zero. The suite that gates the crawl workflow was green because it could not see.

So every check in check-integrity.ts and every count in health.ts now reads its error and throws. Two helpers, rpcCount and rowCount, make that the only way to write one; nine call sites moved onto them.

0085 gives the two slowest check functions their own 60-second ceilings. That is measurably real — the cancellation moved from 8,171ms to 60,223ms, so a function-local statement_timeout does re-arm the timer for the enclosing statement — and it was still not enough for a 78-second query. Filtering to observations that carry a payload does not help either: 188,642 rows instead of 485,568, but they are precisely the big ones and the same 3.7 million buffers come back.

0086 therefore moves that invariant off the request path, to where a long statement is already allowed: pg_cron at 45 past, every six hours, under the 15-minute ceiling 0073 and 0079 established. The invariant is unchanged and still exact over the whole corpus; what changes is that the answer is stored with its computed_at, and the suite fails on a stale row as well as on a non-zero count — 0077's argument, applied to a check rather than a board.

First honest run of it: 0 across 485,798 observations. The invariant holds; we simply had not been asking.

sync-migrations reports the tree rebuilds the database exactly, and the bootstrap cron job used to populate the first row was unscheduled after it did.

Merge the dealerships that were seeded twice

10:36 pm AWST · a391724

132,730 live listing rows resolved to 129,190 URLs when entry 14 of docs/DATA-QUALITY.md was written; by this evening it was 156,142 rows over 151,550. The gap is one dealership seeded under two source ids — a rename (GWM Haval to GWM, SsangYong to KGM), a group prefix, an abbreviation — whose second domain redirects to the first, so both crawls stored the identical URL and every published count included that yard twice.

0078 made the published number distinct URLs, which fixed the arithmetic and not the corpus. This fixes the corpus.

0082 gives the schema the word it was missing. A redundant row is neither deleted — that would take its observations with it, and they are append-only — nor delisted, which would have manufactured 4,596 sales out of a filing error and poisoned the one signal days-to-turn is built on. It takes status = 'merged' and names the row recording the same page, with a constraint making that pairing compulsory both ways: a retirement names its survivor or it is not a retirement. A third status rather than a flag, because forty-odd SQL functions and every page already ask for 'live' or 'delisted' by name, so 'merged' drops out of all of them at once; listing_current excludes it outright for the readers that filter nothing, like the insight corpus, where a duplicate page would vote twice in the price-against-age fit.

0083 is the merge, computing its targets at apply time rather than from a hard-coded list. The surviving row for a URL is the one whose source's base_url is the host the page is served from — a fact already in the data, since the redundant id had been storing absolute URLs on the survivor's host all along. It cuts against taste in both directions: ftgchery beats cheryferntreegully, mandurahgwmhaval beats mandurahhaval. For the 32 URLs where no id matches the host (cheryadelaide and cherynailsworth both redirect to cherymainnorth, which no source is named for) the earliest-observed row survives, which is arbitrary and recorded as such.

Ten live pages only a retired id had seen moved to the surviving id rather than being retired — nothing would ever have looked at them again. Delisted leftovers stayed put, because check_delistings_from_partial_runs judges a delisting against the crawl runs of its own source. Before: 156,142 live rows over 151,550 URLs. After: 151,604 of each.

0084 makes it an invariant — no URL live under more than one source id — and check-integrity.ts runs it on every crawl. Discovery will seed a yard twice again; it now costs a failed run the next morning instead of three days of publishing a number 3% too big.

retired row; the home page's cut tape drops events whose listing it cannot describe instead of rendering a price against a blank car; and the three surfaces where a retired id is still reachable say what happened rather than reading as a dealer with empty lots.

What the merge does not fix is in entry 14 with counts: 192 duplicate vehicle rows, 42 of 6,620 price-change events still counted twice by market_pulse and model_movers, and 8,701 observations that belong to retired rows and stay in the total.

clean, sync-migrations reports the tree rebuilds the database exactly.

Wire the checks to something that runs them

09:10 pm AWST · 90f1634

The stale-board check landed in health.ts, and health.ts is run by no workflow at all. Its alerts were firing into an empty room, which is defect 7's exact shape: a rule that existed and was never called.

So the board assertion also goes into check-integrity.ts, which the crawl workflow does run and which fails the run when it trips. A board whose refresh has stopped puts an empty market on screen over 131,000 cars, and data that is wrong by omission belongs with the other invariants rather than only in an operational one-liner.

health.ts is now a workflow step too, after integrity, so the quieter signals it carries reach somebody.

Verified both paths by making them fail: market_make_board backdated 18 hours produced "FAIL every precomputed board is fresh" and exit 1 from check-integrity, market_state_board backdated 20 hours produced the ALERT and exit 1 from health, and both timestamps were restored to the values they held before.

Compute the latest run per source in the database

09:09 pm AWST · 6a672ee

listings.

Three causes, each hiding the next.

/coverage took the newest crawl_run per source regardless of whether it had finished, and an in-flight run's counters are zero until it writes stats at the end — so with 24 sources in flight at any moment, roughly two dozen rows read zero at all times.

Reading the newest *finished* run instead was not enough: a run abandoned when a crawler is stopped has finished_at set so the lock clears, and counters still at zero. Twenty-four such rows were created at 18:32. The check became finished *and* it did something.

That still left dashes, because the page fetched every crawl_run and reduced in TypeScript. There are 4,244 runs, PostgREST caps a response at 1,000 rows server-side, and 1,397 runs are newer than Westside's last good one — so it was never in the data the page received. Asking for limit(8000) changed nothing; the ceiling is the server's, not the request's.

latest_source_runs() now returns one row per source, computed in the database: the newest run for whether a crawl is running, and the newest run that finished and did something for the numbers. ~700 rows instead of thousands, and no cap to outgrow.

Fourth time today an unbounded PostgREST read truncated at 1,000 without saying so — after probe-sitemaps' source query, the insights rotation, and the vehicle lookup in the basis evaluation. The pattern is in DATA-QUALITY entry 16; this is the variant where the fix is to stop fetching rows you do not need.

Two more reasons /coverage said zero

09:05 pm AWST · ebdd40c

The first fix — read the last finished run rather than the in-flight one — did not clear Westside, because two other things were wrong.

A run abandoned by a stopped crawler has finished_at set so the lock clears, and counters still at zero because it never reached the point of writing them. Twenty-four rows like that were created at 18:32 when I stopped the crawler to pick up the new rate settings, and "newest finished run" happily selected one. The check is now finished *and* it did something — a page fetched, a listing found or extracted.

And the run query had no limit, so it took PostgREST's default 1,000. One pass over 697 sources writes about 700 rows, so this page could see roughly 1.4 passes into the past; any source crawled before that read "not yet crawled", on the page whose purpose is saying when a source was crawled. Now 8,000.

Third instance today of an unbounded PostgREST read quietly truncating at 1,000, after the sitemap sweep's source query and the insights rotation.

A source being crawled looked like a source finding nothing

09:03 pm AWST · 65972ad

listings and had been crawling for three minutes.

/coverage took the newest crawl_run per source regardless of whether it had finished, and an in-flight run's counters are zero until it writes its stats at the end. So every source currently being crawled read 0 found, 0 extracted, 0 new — on the page whose entire job is to say which sources are working. With 24 sources in flight at any moment, roughly two dozen rows were lying at all times, and the faster the crawl got the more of them there were.

Now two maps: the newest run decides whether the row says "running", and the newest *finished* run supplies the numbers, so what is displayed is always something somebody counted.

Record the discovery channel that was inside the corpus already

08:25 pm AWST · 7a0acb8

Every marketplace listing carries a dealer_name. 842 distinct names across 7,778 listings, and 730 of them matched no source we held — the largest discovery pool available, sitting in a column we had been storing all along.

A trading name guesses a domain far better than a group name does: inventing domains from group names ran at 35% once and zero twice, and this ran at 19% across 89 probes. The misses cost nothing, because a domain that does not exist fails at DNS before a request is made.

Sixteen sources seeded today across WA, VIC, NSW and ACT. Four of them were reached only after stripping the "Pty Ltd" a marketplace carries in a trading name and a domain never does, and several only through the sitemap fallback added to probe-candidates this afternoon.

The argument for seeding a dealer whose cars we can already see: Zai Motors advertises 63 on Autotrader and 880 on its own site. Maddington Isuzu UTE, 69 against 377. Melville GWM, 48 against 173. The marketplace is a shop window, not the yard.

Expect the rate to fall below 15% from here — what is left is smaller dealers whose domains are less predictable.

A model year too thin to measure meant no age correction at all

07:44 pm AWST · 05530b1

Taj valued a Tesla and got $42,000 where the 2025 listings ask early-to-mid fifties. Reproduced: 2025 Model 3, 15,000 km, $42,959 against a 2025 median ask of $54,888.

The year shift only fired when *both* the subject's year and the comparable's had a measured price level, and levels needed four cars in a year. Model 3 2025 has three. So the subject's year had no level, no comparable was shifted, and the estimate fell onto whichever year had the most listings — 130 of the 143 comparables came from 2021-2023 and sat at their own years' prices. A 2023 Model 3 asking $39,888 was being treated as a 2025.

This is a regression from 0.3.0. The pooled kilometre slope used to smuggle in some of the age correction, because older cars carry more kilometres; splitting the terms removed that accident and then declined to replace it whenever the year was thin.

Levels now need three cars rather than four, and years still without one are filled from the median year-on-year step across the years that do — robust to a single odd year in a way a first-to-last slope is not — applied outward from the nearest measured year and no more than two years past the measured range. Beyond that the step is describing a market it has not seen, and the nearest real level is the better answer. Holes inside the range interpolate between their neighbours.

Correcting by an estimate is wrong by a little. Correcting by nothing was wrong by four model years.

Held out with the harness now importing the shipped function rather than its own copy, which is why this was not caught earlier: thin-year subjects -20.2% median error, dense-year subjects unchanged at $3,148 against $3,149. The Tesla reads $52,649 and the well-populated case is untouched — a 2023 Model 3 still returns $39,100 against a $39,888 median.

Note what the shutdown handler does not fix

06:39 pm AWST · 5abef75

Stopping the crawler to pick up the new rate settings tripped traversal. Each carried a dealer's own "this vehicle has sold" declaration, so the evidence was sound; what was missing was the precondition.

Delistings are written when a source finishes traversing and the run row is marked complete after that, so a kill landing between the two leaves real delistings attached to a run the handler then marks incomplete — which is exactly what the invariant is built to reject. The handler closes the lock; it cannot un-write the delistings.

The invariant was left alone. `complete` proves the index was fully readable, which is the thing that stops a site outage becoming a hundred fabricated sales, and weakening it to clear a failure I caused would be the wrong trade. The six were reverted by hand and will be re-detected on the next complete pass, since the cars really are sold.

The lesson is about when to stop a crawler rather than about the rule: stopping between sources is free, stopping mid-source is not. Recorded on the handler so the next person reaching for kill knows what it does and does not cover.

Crawl at the rate sites actually tolerate, and notice when they object

06:26 pm AWST · 980b9ca

sake of it — fast and properly, without costing data quality.

Three changes, in the order they matter.

**A 429 or 503 now slows the host down.** Nothing did before: the status was recorded as an error and the next request went out at exactly the same rate. That was survivable at eight seconds and would not be at two. backOffFor() doubles the host's gap for the rest of the run, honours Retry-After in either of its forms, caps at 60s and never decays back — a published Crawl-delay is a site's stated tolerance, 429 is its live one, and the live one wins. Requests are still not retried; a block is recorded, not fought. Only the pace of everything after it changes.

**The default gap drops from 8 seconds to 2.** It was undocumented, and with one request in flight per host and 24 sources at once it capped the whole crawl at about three pages a second — the ~8,600 an hour passes actually managed, and why a full pass of 678 sources took most of a day. Nothing had asked for eight seconds. A site's own Crawl-delay still wins wherever it is larger, which the fetcher enforces with a max() that no configuration can undercut.

**Jitter is proportional rather than flat.** A flat 0-1.5s was noise at 8s; at 1s it was most of the delay again, so a site asking for 1 second was quietly being given 1.75.

Both marketplaces move to 1s. CarsGuide publishes Crawl-delay 1, so that is its stated tolerance and it now gets exactly that. Autotrader publishes no rate directive at all, so its 1s is reasoned from the sibling site on the same platform — recorded in terms_notes as an inference rather than dressed up as compliance with something it never said.

Data quality is protected by the thing that makes speed safe rather than by staying slow: the rate is now a starting position that a site can correct.

Marketplace depth: what was actually limiting it, and a note that was wrong

06:18 pm AWST · 8222232

What was limiting coverage turned out not to be the rate or the budget. Measured over completed runs, Autotrader averaged 870 detail fetches against a 2,500 budget having found 3,841 listings, in 95 minutes — neither out of budget nor out of time, but out of URLs it had not already seen. Discovery depth was the ceiling. Index pages raised 400 to 1,500 and detail pages 2,500 to 8,000 on both marketplaces, which is affordable because the crawler runs 24 sources at once and a long run there occupies one slot.

Also corrects a note that was simply false. market:autotrader's terms_notes claimed "their robots.txt asks for Crawl-delay 1, so this stays twice as conservative as requested". Fetched today, Autotrader's robots.txt is Allow: / with disallows for deep paths, fuel-type filters and query strings, and no rate directive at all. That sentence is CarsGuide's — both sources carried byte-identical terms_notes, so it came across with the copy. The 2s we use is our own courtesy against no published rate, which is a different and weaker claim than the one the file was making. Both stay at 2s.

The arithmetic is in the doc rather than implied: 77,000 listings plus the index pages to find them is around 81,000 fetches against one host, four to six days of continuous crawling at the measured page rate. It accumulates over weeks. Making it arrive sooner means raising the rate, which is a decision about how this project treats a named company, not a technical one.

Stop fetching accessory catalogues as if they were cars

06:04 pm AWST · 9bcb625

First pass after 107 sources moved onto their own sitemaps: 367 "extractor returned null" errors, and the paths behind them were /pages 141, /new-vehicles 58, /models 41, /accessories 29, /special-offers 14, /click-to-buy 10, /blogs 7. Brighton Mazda found 301 URLs and extracted 80.

The sitemap fallback accepted any same-host URL with two segments whose last segment contained a digit — so /accessories/mazda2/ and /models/cx-5/ both qualified, because "mazda2" and "cx-5" contain one. Roughly 350 requests a pass to real dealers for pages that were never going to hold a car.

Two changes. A path list for pages that are about cars rather than cars, kept separate from NOT_A_LISTING because that one describes sections of a site and this describes pages that pass the shape test by accident. And the shape test now wants four consecutive digits rather than any digit, which is the rule discovery already applies to detail links — every real id shape in the corpus clears it, since stock numbers run six to eight digits and year-slugs start with a year.

Checked against eleven paths, five junk and six real, covering iMotor, Cox Radius, the /stock/details/ shape, easycars and the year-slug form.

This is the cost of the sweep working: a sitemap lists everything a site wants indexed, so the deeper the reach the more of the site's own furniture arrives with it.

Autotrader is in scope and has been for two days

06:01 pm AWST · 38a0a74

The disabled-sources table listed it under "carsales, Autotrader, Drive, Gumtree — WAF-blocked, out of scope". That was true of a 2026-08-10 survey which recorded a Peakhour 403 on everything, and stopped being true the next listings, the Disallow rules cover query strings and paths of nine or more segments while listing URLs are seven, and it shares CarsGuide's platform so the existing extractor reads it unchanged.

It has been enabled and crawling since — 5,176 live listings — while the document said it was out of scope. The durable record in source.robots_notes was right the whole time; the table was the stale copy.

Which is the same failure this section already documents for four dealer

Value a GLX against GLXs: trim gets its own term

05:44 pm AWST · b67d339

listing he could see was $35,000-$38,000. He was right, and the cause was that trim — worth more than anything else this model corrects for — had no term at all.

The comp set held 49 GLS, 46 Exceed and 9 GSR against 18 GLX. For 2021-23 those trims ask a median $38,990, $41,980 and $44,888 against the GLX's $35,990, so roughly three-quarters of the weight sat on dearer cars. Two reasons it could not right itself: badges were compared as exact strings, so "GLX (4WD) 5 Seat" counted as a different trim from "GLX", and a 1.5-against-0.8 weighting cannot outvote a set three-to-one against you.

It was bending the kilometre slope too, exactly as mixing model years used to. Pajero Sport: -$1,487 per 10,000 km pooled, -$999 within year, -$781 within year for GLX alone. Trim variation was being absorbed into the kilometre coefficient and then extrapolated across 90,000 km gaps.

Comparables are now restricted to the subject's trim wherever there are at least eight, and where there are not, the others stay and are moved onto the subject's trim by measured price level — the same correction that already handles model year. Badges are normalised first, so drivetrain and seat descriptors stop splitting a trim into strangers. The slope is centred within trim as well as year.

Held out over 31,939 cars in 20 trim-varying models: median error -31.7%, p90 -37%, median bias -$69. Per model: Mazda CX-5 -42%, Isuzu D-Max -39%, Nissan Navara -38%, Pajero Sport -33%. This is the largest single improvement the valuation has had.

The case that prompted it now reads $37,560 with 20 all-GLX comparables, and confidence rises from 0.84 to 0.90 because the comparables are finally comparable. A Pajero Sport Exceed asks $46,584 against the GLX's $37,560, which is the $9,000 the model previously could not see.

Normalisation is deliberately conservative: "Sport GLS QF" does not reduce to "GLS". Over-merging two trims that differ by ten thousand dollars is the more expensive mistake, so an unrecognised badge costs a comparable instead.

Also, both of Taj's display asks. The range now says "excluding government charges" under the number rather than only in the workings — a dealer holding this against the ads in front of them needs to know which money it is first. And confidence is stated, not implied by the width of a bar: a filled pill for high, outlined for low, with the score beside it. Yellow carries black, per BRAND.md.

Always value in excluding-charges money, and restate drive-away ads to match

05:16 pm AWST · a377c41

they are reading the prices from the drive away prices." Measured on 9,811 held-out cars advertised excluding charges, the range ran $353 high at the median and over-valued 53.2% of them. He was right.

A third of the corpus advertises drive-away and a sixth excluding government charges. valuation.ts computed on whichever basis dominated a comp set and down-weighted the other, converting nothing — so the answer depended on who happened to be advertising, and a car surrounded by drive-away ads was valued in drive-away money.

Now every comparable is restated excluding charges and the range always reports that basis. Drive-away ads lose their state's stamp duty; the slope and year levels are fitted on converted prices, so the whole model works in one currency rather than a blend.

The rates are legislated, not measured, and that was a decision. Comparing drive-away against excluding-charges listings of the same make, model, year, condition, kilometre band and state gives NSW +11.5%, VIC +10.1% — but QLD -1.2% and TAS -11.6%. A drive-away price cannot sit below the same car's excluding-charges price, so those pairs are not the same car; badge and trim vary inside a cohort and which cars a dealer advertises drive-away is not random. The corpus cannot calibrate this. Each schedule is read from its own revenue office, cited and dated in govt-charges.ts, with the arithmetic worked by hand in test-govt-charges.ts rather than asserted against the implementation.

SA and ACT return null and are left unconverted: RevenueSA blocks automated access so its schedule was only available from third-party sites, and the ACT's published table read "from 1 February 2027" when checked, which is not today's rate. This repository does not encode a tax rate from a blog or from next year.

Stamp duty only. Registration and CTP also sit inside a drive-away price and are not subtracted, because they cannot be computed honestly — registration turns on term and vehicle class, CTP on insurer and sometimes the driver, and a dealer may fold in three months or twelve. The residual shows up exactly where it should: bias falls from $353 to $175, and $175 is about what a third of comps each carrying $600 of un-subtracted on-roads would leave. It is disclosed on the page rather than closed with a guess.

Median error also improves 0.6%. Discarding drive-away comps altogether scores better again on both (bias $0, error -1.5%) and is not taken: it throws away a third of the comp set, which the well-populated models in this evaluation can afford and a thin cohort cannot, and its mean bias is -$594.

Read the dealer's address from the node that actually has it

04:37 pm AWST · 7c07c9b

suburb and postcode were 0% across all 58,991 live listings on the jsonld extractor, against 97% on dealer-studio. The field was being read correctly from the wrong node: schema.org puts the seller on the vehicle, and many of these sites put nothing there at all. melbournebmw's Car node has no seller whatsoever, while an AutoDealer node beside it gives South Melbourne, VIC, 3205 in full.

The vehicle's own seller still wins where it exists. The page's dealership is a fallback, and only when the page names exactly one business with an address — a group page listing several yards cannot say which one sold this car, so it contributes nothing rather than guessing.

Measured across ten sampled sources rather than assumed: five now yield a complete address (Bunbury 6230 WA, Southport 4215 QLD, Ringwood 3134 VIC, South Melbourne 3205 VIC, Chatswood 2067 NSW), two more yield a dealer name, three still yield nothing. So roughly half the platform, filling in as listings are re-observed rather than all at once.

Found while checking a 0% in the dealer-studio extractor test, which turned out to be a fixture artefact — that platform is at 97% in the live corpus. The fixture was wrong and the number beside it was real.

The sweep's other half: 273 delistings against 4

03:31 pm AWST · 11beac3

The sitemap work was done for coverage. Measured on the first pass to use it, its bigger effect may be the sold signal.

A twelve-card index cannot show that a car has left, because you only ever saw twelve. A sitemap lists the dealer's whole current stock, so a listing we hold and the site no longer advertises stands out. Previous pass: 388 sources, 4 delistings, 7 price changes. This pass: 73 sources, 273 delistings, 388 price changes.

The evidence guard held. 27 sources had delisting candidates rejected because the page still answered 200 — missing from the sitemap, not sold — Bayford alone with eight that would otherwise have been recorded as sales. A dealer's sitemap is a cache and it lags, and the confirmation fetch is the difference between evidence and inference.

Delisting is how this product infers a sale, so this is a better-founded sold signal and not only more rows.

Also restates the pace, because depth costs frequency: 169 pages per source against 27.5, about 32 sources an hour against 73, so a full pass is nearer 21 hours than 9 and the six-hourly cron will usually find one in flight. The reachable listings arrive over days. That sharpens the hosting question rather than softening it.

Bump the model version, because the model changed

01:01 pm AWST · f7aac51

MODEL_VERSION is stored on every valuation row and is the only thing distinguishing one generation of the arithmetic from the next. The km/year split shipped an hour ago without touching it, so valuations computed this morning under the pooled slope and this afternoon under the split terms would both be filed as spotlot-val-0.2.0 and be indistinguishable to anyone reading the table later.

Nobody was served a stale number — model_version only dedupes the stored record, and the range itself is always computed fresh — but the stored history would have been quietly wrong about what produced it.

0.3.0 carries the reason with it, so the next reader does not have to find this commit to know what changed.

Also fixes the API docs page, which advertised spotlot-val-0.1.0 — two generations behind what the endpoint actually returns.

A killed crawler should close its own run rows

12:56 pm AWST · bc4569b

I stopped the 06:43 pass deliberately at 12:02 so the next one would pick up the new sitemap configs. The 12:43 pass then refused to start:

another crawl is in flight (dealer:accars since 13/08/2026, 12:02:02 pm)

It was not in flight. The process was gone; its 22 open crawl_run rows — one per source in the concurrency window — were not. anotherCrawlIsRunning() treats any unfinished row started within STALE_RUN_HOURS as live, and closeAbandonedRuns() only reaps rows older than that same window, so nothing could clear them for two hours. The guard was working correctly on a lie, and it cost the exact pass the stop was performed for.

crawl.ts now tracks the runs it opens and closes them on SIGINT/SIGTERM, marked incomplete — a killed traversal saw part of a site, and a partial traversal must never let a delisting be inferred, which is the rule the budget-exhausted path already follows. Rows closed normally are deregistered so the handler has nothing to do for them.

This does not replace closeAbandonedRuns(): a laptop that sleeps or a SIGKILL still leaves rows behind, and the two-hour reaper is the backstop for that. It removes the case where the crawler was asked politely to stop and left a lock behind it anyway.

Correct age and kilometres with separate terms, not one doing both

12:37 pm AWST · cd0f3da

Taj asked what the adjusted price represents and whether it skews valuations. It does, and the fix is not the one I first proposed.

The valuation shifts each comparable to the subject's odometer using a Theil-Sen slope fitted across the whole comp set. That set spans model years, and because older cars carry more kilometres the slope absorbed age depreciation too: pooled it is 1.6x to 3.7x steeper than the same-year figure on every high-volume model (Ranger -2,479 against -862 per 10,000 km; Sportage -2,657 against -716). Nothing else adjusted price for age — year only reduced a comparable's weight — so one coefficient was carrying both effects and over-shifted any subject whose odometer was unusual for its year.

My first suggestion was to fit the slope within years and stop there. Held out over 46,576 cars in 40 models, that is 0.5% WORSE: stripping age out of the slope while adding no age term leaves age uncorrected. Recorded because it was the obvious fix and it was wrong.

What works is both terms. Kilometres measured within years, plus each model year's median ask at a reference 80,000 km, so a comparable is moved in years and in kilometres separately. Held out on identical comp sets and weights:

slope median err p90 err mean err none $4,102 $13,993 $6,143 pooled $3,241 $11,198 $4,843 <- what shipped within $3,257 $11,541 $4,944 km+year $2,804 $10,534 $4,410 <- this

13.5% better median error, and better on all 12 of the highest-volume models individually. The cap moves 25% to 35% because the shift now carries two effects; isolated, the year term alone is worth 11.5% and the wider cap alone is 3.7% worse, so the extra room only helps once there is something legitimate to put in it.

What it does to the number on screen, which is a separate question from p10 -8.3% and p90 +8.3%, and 13.5% of cars move by more than 10%. Those are the cars whose odometer sits far from their comp set, which is exactly where the single coefficient was wrong.

scripts/eval-km-slope.ts is the harness, kept so the claim can be rerun rather than believed. It predicts advertised prices, which is what this tool estimates and all Australia publishes — it does not prove the estimate is closer to what a car sells for.

Entry 13: orange has now held three readings

12:05 pm AWST · 860531d

The history table added this morning has its third row, and the winner has not fit growing from 88,407 cars to 94,845.

That is more than gold managed — it held for two readings and then became orange, which is what prompted the entry. Three in a row across an 8,000 listing increase is not proof the effect is real, but it is the first time this insight's identity has been observed holding still rather than recalled as having done so.

Rover is a car; and let the sweep see badges too

12:01 pm AWST · 31fd834

Two things arrived in the same review batch and needed opposite treatment.

A Rover 75 Cdti came in from CarsGuide. Rover is a real British marque and was missing from the canonical list, so it was being held for a human and would have been dropped — the opposite failure from the caravans beside it. Added, with a note that a bare "Rover" means this marque: feeds that mean Land Rover say landrover, land-rover or range rover, all of which are already aliased.

The four caravans — Empire Luxor, Elite Eildon, Vivid Caravans Nomad, Snowy River SRC21S, every one badged CARAVAN — were caught at ingest by this morning's badge rule but not by recanonicalise, which only tested make and model. So rows already stored under the old rule sat in the queue forever waiting for a human to decide something the rules already decide. The sweep now applies the same badge test as ingest, which is the point of having one vocabulary rather than two.

Queue is clear and all ten integrity checks pass.

Entry 16: the same bug four times in one day

11:30 am AWST · b9ea94b

const { data } = await client.from(...) — the error discarded — produced four confident wrong answers on 2026-08-13: probe-sitemaps reporting "no sources matched" for 119 sources whose id=in.(...) URL was too long; a throwaway audit saying "pending aliases: 0" when five were pending and the column names were wrong; check-integrity printing "-1 row(s) ... against -2" when the data was perfect and the RPC had timed out; probe-candidates calling a dealer unusable for HTTP 404 when it publishes a sitemap nobody asked for.

supabase-js resolves either way and returns { data, error }. Take only data and a failed query becomes indistinguishable from an empty result — and since empty results are normal, every caller already has a plausible branch waiting for it. The failure does not look like a failure; it looks like a fact.

This codebase is unusually exposed to it because absence is meaningful model and it makes the empty branch the well-trodden one.

Records what is already defended — the ingest path drops errors too, but unique indexes on vehicle.vin and vehicle.fingerprint turn a swallowed error into a constraint violation rather than a duplicate vehicle, which is luck rather than design and is why the crawler was not rewritten in a hurry today.

52 call sites still destructure only data. Sweeping them is a day's careful work across the crawler's core, and doing it fast to close an entry would be the wrong trade on a live corpus. The two producing wrong answers were fixed.

An integrity check reported corruption it never observed

11:01 am AWST · f4e122c

check_fields_empty_flag() counts observations whose flag disagrees with their payload across 393,000 rows. Through PostgREST it now exceeds the API role's statement timeout; run directly it answers in seconds and says the data is perfect — 183,867 flagged as content, 183,867 actually content, 0 mislabelled.

check-integrity.ts destructured only `data`, so the timeout arrived as null and the sentinel defaults were printed as counts:

FAIL every observation's fields_empty matches its payload -1 row(s) flagged empty while holding content; -1 flagged as content against -2 that are

Negative rows are impossible, which is the only reason this was obvious rather than alarming. The failure mode matters more than the timeout: a check that cannot run must say so. Reporting a failure it never observed sends somebody hunting corruption that is not there, and teaches the next reader that a red line in the integrity suite is noise — which is the one thing that suite cannot afford.

0080 gives the function a 60-second ceiling of its own, following 0073 and 0079. Sixty rather than fifteen minutes because this one runs inside a request, and a check that takes a minute is already saying something.

Third statement-timeout failure this week, and the third time today that a discarded error turned into a confident wrong answer.

The daily rollup had been failing silently for two nights

10:46 am AWST · 9ab664a

pg_cron's daily-rollup job died at exactly 00:02:00 on 2026-08-11 and 2026-08-12 — "canceling statement due to statement timeout". write_daily_rollup joins listing_event to listing_current and scans it again for the day's delistings, and at 137,000 live listings that outgrew the cron role's 2-minute default. Measured after the fix: 188 seconds.

Migration 0073 gave depth-boards a 15-minute ceiling for exactly this reason and left this job on the default. 0079 does the same for it. Two jobs, one shape of problem, fixed six days apart because the first fix was scoped to the job that happened to be failing rather than to the class of job.

Nothing alerted for two days, because a cron job that fails silently looks exactly like one with nothing to do. It was caught by 0077's stale-board check noticing the tables were 36.3 hours old — which is the argument for that check existing.

2026-08-12 has no rollup and is not being backfilled. The function takes a date, which makes a backfill look safe; only cuts, rises and confirmed_exits are filtered by it, while live counts, vehicle counts and every ask quantile read current state. Writing today's market under yesterday's date would produce a row that looks perfect and is a lie. The gap is recorded in DATA-QUALITY entry 15 instead.

Annotate the survey's "browser UA" so it is not read as licence

10:05 am AWST · 8c1ded9

docs/research/dealer-site-directory.md records its method as fetching each site with a "standard browser UA". That was a one-off manual pass on 2026-08-10, before the crawler existed, and it contradicts a rule at the top of this repository.

Verified rather than assumed that it describes nothing current: fetcher.ts sends SpotlotBot/0.1 and there is no browser user-agent string anywhere in src/ or scripts/.

The paragraph is left as written — it is a record of what was done, and rewriting it would make the tree tidier and the record false. The note says plainly that it is history, not practice.

Both sweep batches done: 15,542 listings reachable, 105 sources

09:56 am AWST · ccba77e

Batch 2, run against the 82 sources that were not iMotor-shaped but sat under 25 listings or carried an unmatched URL shape: 68 have a usable sitemap, 30 gain more than 20 listings, +5,177. Footscray GWM Haval held 8 against 530, MMG Auto 11 against 504, the three Rex Gorell franchises 332, 319 and 264.

With batch 1 that is 105 sources now discovering through their own sitemap and 15,542 listings reachable that were not yesterday.

Reachable, not held — and fetching them is the constraint this document exists to measure. At 200 detail pages per source per pass and four passes a day, with the rotation taking unseen listings first and stalest second, the backlog clears over days. Nothing here raises maxDetailPages: a pass cannot spend more than its budget, so the cap is not what is binding.

Defer the freshness clock's first sync out of the effect body

09:30 am AWST · 9b0f44d

eslint flags setNow(Date.now()) called synchronously inside useEffect, and it is right: that schedules a second render inside the first.

The sync itself has to happen. The server renders `now` as computedAt, so a page served from the CDN at age 40s would display "0s ago" until the first tick — which is the opposite of what a staleness indicator is for. Moved to a setTimeout(0) alongside the existing interval, with both cleaned up.

Also drops exactCount from queries.ts. It was orphaned when the eleven separate COUNT(*) round trips were replaced by the coverage_counts RPC, and has been dead since.

tracked probe-tmp.ts from an earlier investigation, deliberately left alone.

Reject the all-nines odometer, keep the zero one

09:02 am AWST · 619d9c3

A 2010 Camry is advertised at 9,999,999 km — typed by whoever built the listing to mean "not entered". Stored as a number it is a reading of ten million kilometres, and it feeds medians, the price-by-age-and-kilometres fit behind every insight, and the comparables behind a valuation.

The ceiling is 1,000,000 rather than something tighter because the corpus holds real high-kilometre commercial vehicles and their distribution is continuous: 799,686, then 704,323, then 636,700, all the way down. Only the sentinel sits outside it, in 13 observations of one listing.

Null rather than clamped — we do not know this car's odometer, and a blank field stays blank.

Zero is explicitly kept. The first version of this rejected it, which would have blanked the odometer on 3,545 live listings that read 0 km because they are new cars, where zero is the reading and not a missing value.

Correct the correction: /stock/details/ does buy listings

08:59 am AWST · e3a763b

This section recorded that shape as worth zero, on three measurements that were all accurate: Melbourne BMW 557 held against a sitemap of 558, North Shore BMW 314 against 315, Waverley BMW 417 against 416.

Every one of those three was already fully discovered by walking its index pages, so the sample could not have returned anything but zero. Run across under-covered sources instead, BMW Sydney goes 27 to 252 and Castle Hill BMW 30 to 175.

What decides the value is not the URL shape but whether that source's index traversal was already reaching its stock — which is a property of the source, not of the pattern.

Third time today I have generalised a measurement past the sample that supports it, so the lesson is now stated as the general one: measuring "does this help?" on subjects that need no help returns no, every time.

The iMotor sweep finished: +10,365 listings reachable

08:40 am AWST · e7330cf

119 sources probed, 115 publish a usable vehicle sitemap, 75 gain more than 20 listings, total upside 10,365. All 75 now discover through their sitemap with the HTML stock page kept as a secondary index, so a truncated pass still refreshes prices.

Trivett held 33 against 1,131 in its own sitemap. Llewellyn MG 13 against 507. Jarvis Ford 12 against 387.

The sources already well covered gained nothing, which is the result that makes the rest credible: AMR Used Cars 14 held against a sitemap of 13, Llewellyn Motors 645 against 504. A sitemap lagging what we hold is normal — it is a cache of the site's own index, not a second opinion.

Replaces the partial figure of 38 sources this section carried while the run was still going.

"Unknown" is a placeholder, not a marque

08:39 am AWST · 28004a3

Four Penrith sites each publish /cars/used-grey-2026-unknown-unknown-4623 — make "Unknown", model "Unknown", no price, one stock id repeated across the group. Their CMS emitted it. It is a real page and not a car anyone can value, and it was sitting in the review queue on all four.

Excluded by rule rather than by hand. The review queue is supposed to mean "a human still has to decide this", and this is now decided; and check-integrity requires every out-of-scope vehicle to be one the rules exclude, so a hand-set flag would fail the invariant built to catch exactly that kind of edit.

The two caravans in the same batch need no new rule — they are already caught by the badge check added this morning. They reappeared only because the running crawl loaded its code at 06:43, before that change existed, and will stop arriving once the next pass starts.

Ask the site for its sitemap before declaring it unusable

08:38 am AWST · a52acdb

probe-candidates guesses inventory paths and, when every guess 404s, reports "inventory HTTP 404" and moves on. That verdict has already been wrong in an expensive way: dealer:darwingwm and dealer:riverinavolkswagen were both withdrawn on exactly that note, and both publish a stock sitemap naming 164 and 96 vehicles, linked from their own robots.txt. A guessed path answering 404 says nothing about whether a dealer publishes its inventory.

It now falls back to the site's own sitemap, requires at least 10 vehicles in it, and then opens one through the existing confirmVehicle check so the verdict still names the extractor and a real car with a price — counting links proves a sitemap exists, not that anything can be read out of it.

Of the 20 candidates probed this morning, exactly one is rescued by this: kerryholden.com.au, 42 vehicles, previously discarded as HTTP 404.

The patterns and the sitemap walk now live in src/lib/crawl/sitemap-scan.ts instead of being copied into a second script. They have been wrong once in a way that cost 97 sources, and two copies drifting apart is how that happens twice. probe-sitemaps re-verified against Ipswich Chery after the extraction: 13 held, 129 in sitemap, 3 fetches — identical to before.

Kerry Holden is deliberately NOT seeded despite passing. All 42 of its stock ids are already inside Darwin GWM's 164 — same group, same Darwin stock on a second domain — so seeding it would buy 42 duplicate listings and spend the fetches twice. That is the containment problem in docs/COVERAGE-CEILING.md, and finding a usable site is not the same as finding a useful one.

Name the row instead of reaching for the last one pushed

08:10 am AWST · ff11f78

applyOne was being handed rows[rows.length - 1], which is the row just pushed only for as long as nobody adds a second push or reorders the loop. Bind it to a variable and pass that.

DEMO: date the duplicate measurement so it stops half-reconciling

08:09 am AWST · 3804bcb

The headline says 129,286 live and the paragraph below said 132,730 rows minus 3,540 duplicates, which is 129,190. Both are right and they were taken twenty minutes apart, so a reader checking the arithmetic finds a 96-listing hole and no way to tell which number to trust.

Stamped with the time it was measured, and says plainly that both move with every crawl while the 2.7% is the durable part.

Measure the model pages before they become the next /market

08:03 am AWST · 4662dd0

/model/toyota/hilux is 5.0s cold, 0.56-1.16s warm. model_ticker alone is 1.95s and 74,646 buffers for a single model.

The cost is not the make/model lookup — vehicle_make_model_idx covers that. It is listing_current_lean resolving each listing's latest observation at roughly 37 buffers per listing, which is exactly what migration 0066 recorded about the unlean view: reading it is O(n x per-row work). The uncomfortable part is that n is how many of that model the corpus holds, so a popular model gets slower every time coverage improves.

Not fixed here. cached() absorbs it for everyone after the first visitor, so it is a cold-path cost rather than a live failure, and the real fix is a denormalised current-state column — a design change that overlaps work already in flight on that view. Recorded now so the measurement exists before it turns into a page that 500s, which is how /market announced the same problem.

DEMO: state freshness as a share, so it stops contradicting the headline

08:01 am AWST · 8919cc0

The paragraph said "of 132,903 live listings" four lines under a headline saying 129,286. Both are right — one counts rows, the other distinct pages — and a reader totalling them would reasonably conclude one is wrong.

Stated as 88% instead, with the row counts in parentheses and a pointer to the paragraph that explains the difference.

DEMO: the freshness number, which was measurable and unstated

08:00 am AWST · 7ff1f86

Of 132,903 live listings, 116,449 were re-observed in the last 24 hours and every remaining one within three days. Nothing older exists in the corpus — not mostly, zero.

This is the whole thesis of the product stated as a number, and it was nowhere in the demo script. "Most tools photograph the market once; Spotlot watches it" is a claim; "every live listing was seen again within three days" is the evidence for it, and it costs one query to check in front of somebody.

Included the query so it can be run live rather than believed.

Refresh figures: 129,286 live across 677 sources

07:51 am AWST · ccd5677

Distinct-URL counts throughout, matching what the site now publishes. Sources 677 rather than 672 — the five recovered this morning: darwinmazda, darwingwm, riverinavolkswagen, carlinandgazzardvolkswagen, and northlakesgwm, which was not in the corpus at all.

Makes 85, down from 87, as the taxonomy sweep took Bad Boy Mowers, two caravans and a Western Star out of scope.

Count cars, not rows, everywhere the corpus size is published

07:48 am AWST · 9b7ea4e

coverage_counts() feeds the number on the home page, /market, /value and /api, and it was counting listing rows. 132,730 live rows resolve to 129,190 distinct URLs; the other 3,540 are the identical page under a second source id, so every one of those surfaces read 2.7% high.

Distinct URL is the right key rather than the convenient one. Two rows sharing a URL are the same page on the same dealer's site, whatever id discovery filed them under. Two different pages advertising one car — a marque site and its group site — keep their own URLs and are still counted separately, which is correct at this level: the corpus does hold two listings, and valuations dedupe by vehicle where it matters.

This fixes the arithmetic, not the duplication. The redundant source ids still exist and are still fetched twice; retiring them without inventing delistings is entry 14.

Live figure after the change: 129,234.

Entry 14: 3,540 live listings are the same page counted twice

07:45 am AWST · 39060fe

132,730 live rows resolve to 129,190 distinct URLs. The gap is not one car listed on two sites — it is the identical URL stored under two source ids, because a single dealership was seeded more than once: wynnumgwm and bartonswynnumgwm hold the same 364, capalabagwm and bartonscapalabagwm and bartonsgwmhaval the same 250, brightonkgm and brightonssangyong the same 116. The reasons are mundane. GWM dealers renamed from "GWM Haval", KGM is what SsangYong is called now, a group prefix appears on some entries and not others, and ftgchery is cheryferntreegully abbreviated.

COVERAGE-CEILING measures containment and concluded duplication costs fetches rather than correctness, since valuations dedupe comparables by VIN. That holds for a group site restating a marque site's cars under its own URLs. It does not hold here: an identical URL under two ids inflates the count, and the count is published on the home page.

Not fixed yet, and the reason is in the entry. Disabling one id of each pair stops the duplicate fetches but leaves the duplicate rows live, and a delisting has to carry evidence — a config change is not evidence. That needs a migration that retires the losing id's listings, written carefully.

What was safe to do immediately is stop publishing the inflated number, so README and DEMO now quote distinct URLs and say so. DEMO also notes that the pages themselves still count rows and therefore read 2.7% high until the merge lands, which is the sort of thing a demo should say out loud rather than hope nobody totals.

Re-enable four sources disabled by a filter, not by a site

07:40 am AWST · 83d60d0

dealer:darwinmazda sat on the disabled table reading "stock sitemap lists category pages, not vehicles". Its sitemap lists 106 vehicle detail URLs and 3 category pages. The check that disabled it read the file through the sitemap sweep's VEHICLE_PATH filter, which cannot match an iMotor detail URL — so the three it could see were the whole picture it got. The note was a true description of a filtered view and a false description of the site.

Re-checked and re-enabled, each with a detail page re-probed for window.pageData, stockItem and vehicle JSON-LD: darwinmazda (106), darwingwm (164), riverinavolkswagen (96), carlinandgazzardvolkswagen (35).

The two "every inventory path answers 404" notes were not wrong, just incomplete — every guessable path does 404, and the sitemap named in the site's own robots.txt lists the stock anyway.

dealer:villagegwmhaval stays disabled with its reason corrected. It is not "new models only": its sitemap lists 188 vehicles, every one on northlakesgwm.com.au. The dealership was rebranded, so that source was reading a site that had moved. Added as dealer:northlakesgwm — a real dealer nobody was crawling — rather than re-enabling a row whose id names a different business.

Sibling LDV and RAM sites re-checked the same day and left disabled; their stock sitemaps genuinely return nothing.

The lesson for the table: a withdrawal note records what a check concluded, not what is true, and outlives the tool that produced it. Anything disabled for "no stock found" — rather than a 403, a WAF, or a robots disallow — deserves re-running whenever discovery changes.

Record the sweep's blind spot and what it was actually worth

07:28 am AWST · 3207441

97 sources sat at ~13 listings each because the sitemap sweep reported "sitemap 0" for them, which reads exactly like a dealer with no sitemap. Thirteen was not arbitrary: iMotor index pages server-render 12 Algolia hits and load the rest client-side, so walking the page sees twelve cars however many the yard holds. Ipswich Chery renders 12, holds 13, and reports nbHits 129 in the JSON its own page ships.

Partial run, 38 of 119 sources: 24 gain, +2,776 listings.

Also records the audit that followed, because the method matters more than the recognise? That table is what turned up cox-radius at 7% — and a platform at 7% is more dangerous than one at 0%, because the accidental matches make it look like something that works.

And the mistake it exposed. "URLs the sweep cannot match" is not "listings we are missing". The /stock/details/ shape looked like the biggest of the three at 3,287 listings across 35 sources; measured upside was zero, because those dealers were already fully discovered by walking their index pages. The equivalence was true for iMotor and I carried it across two more shapes without re-checking it.

Write each sitemap as it is found, not all of them at the end

07:27 am AWST · 41ac606

A 119-source --apply run died at source 34 and wrote nothing. Every update was still queued behind 85 sources of fetching, so an hour of polite, rate-limited requests produced no durable change and the only evidence it had happened was a log file.

The batching bought nothing to begin with — the fetches are the expensive part and each update is a single row. Now an interrupted run keeps what it has earned and re-running costs only the sources it did not reach.

The same run also has to be started properly. `nohup … &` from a tool call stays in the caller's process group and dies with it; this one is double-forked into its own session with PPID 1.

The /stock/details/ pattern buys nothing, and that is the finding

07:22 am AWST · 27d9dfb

Commit 1b6eb1f said "35 more sources, 3,287 listings", which reads as upside. Measured upside: zero. Melbourne BMW holds 557 against a sitemap of 558, North Shore BMW 314 against 315, Waverley BMW 417 against 416. Those sites were already fully discovered by walking their index pages.

The mistake is worth naming because I made it twice in an hour. "Listings whose URLs the sweep cannot match" is not "listings we are missing". For the iMotor sources the two coincided — 13 held against a sitemap of 129, against an nbHits of 129, on source after source — and I carried that equivalence across to two more URL shapes without re-checking it. The shape analysis finds where the sweep is blind; only a probe says whether anything is behind the blindness.

The pattern stays. A source reporting "sitemap 0" is indistinguishable from a source with no sitemap, and that ambiguity is precisely what hid the iMotor dealers; making the report truthful for 35 sources is worth a regex even when it moves no listings. --apply writes nothing for them regardless, since it requires the sitemap to beat holdings by more than 20.

Net of today's three patterns: iMotor is the real one, Cox Radius is +289, and this is 0.

Correct the Cox Radius claim: +289, not thousands

07:18 am AWST · 75abc00

The comment added in d137b75 led with "Patterson Cheney holds 423 live listings; its /all-stock/sitemap lists 1,931", which reads as though the sweep could not see that dealer. It could. Patterson Cheney publishes a second sitemap, /used-vehicles/, whose URLs are shaped /used-vehicles/<slug> and match VEHICLE_PATH — the sweep has been reporting 3,668 for the site since yesterday's run, as docs/COVERAGE-CEILING.md says in the section I had just read.

Measured properly, on the four sources whose only shape is /view/<title>/<id>: BDK Automotive 126 held against 242, Select Autos SA 50 against 98, Toowoomba GMSV 19 against 144, Xtreme Car Sales 96 against 96. That is +289.

Xtreme landing exactly on its 96 is worth keeping: a pattern that invented matches would have overshot there, so parity is evidence the other three numbers are real.

The pattern stays — 289 listings is worth a regex — but the number in the repository should be the one that was measured, not the one that made the finding sound bigger.

Add the /stock/details/ shape: 35 more sources, 3,287 listings

07:10 am AWST · 1b6eb1f

Third shape found by the same audit. Most of the BMW franchise sites publish detail pages as /stock/details/OAG-AD-26173410/2026-bmw-x3-used, and one wholesaler uses /wholesale-stock/details/… . VEHICLE_PATH wants a single segment after "stock" and these carry three; the year-slug alternative needs a hyphen followed by a digit and these end in letters.

Matched on the /details/ segment plus two following segments rather than the literal "OAG-AD-", so a site that renumbers its ads keeps working.

Checked the remainder rather than assuming it was all upside: of the 8,096 listings still unmatched, 7,650 are Autotrader and CarsGuide, which are marketplaces crawled by facet and have no business in a sitemap sweep. What is left is a long tail of one-off shapes across 22 small dealers — 96 listings at the largest — and is not worth a pattern each.

Assert the boards are fresh, because a stale one fails silently

07:08 am AWST · c98c7d1

Every heavy aggregate now reads a precomputed board, and every page degrades to an empty panel rather than a 500. Together those two make the worst kind of failure: if a refresh stops running nothing throws, the boards simply age, and /market reports "No live listings yet" over a corpus of 131,000 cars until a human happens to look. That is exactly defect 8's shape, an absent number with a comfortable explanation, and it already happened once today.

check_stale_boards() covers all seventeen boards and rollups, table driven so a new board is one row rather than new code. It allows two missed cycles before complaining: 12 hours for the 6-hourly depth and market boards, 36 for the daily rollups. Four boards whose own thresholds can legitimately exclude every row, like a state needing 300 price pairs, are checked for staleness but not for emptiness, because empty is a real answer there.

health.ts prints boards=ok on the quiet line, so a passing run states the aggregates are current rather than merely not having heard otherwise, and raises one ALERT per problem board with its age and limit.

Verified by making it fail rather than by trusting it: a board backdated 20 hours produced the ALERT and exit 1 that fails the workflow, and the timestamp was restored to the exact value it held before.

Teach the sweep Cox Radius detail pages too

07:06 am AWST · d137b75

Having found the iMotor blind spot by accident, I asked the question properly: of every platform, what share of its stored listing URLs would this file's patterns recognise? dealer-studio 100%, nextjs-embedded 98%, jsonld 91%, easycars 86% — and cox-radius 7%.

The 7% is the tell. Those were titles that happened to contain a hyphen followed by a digit, which `\d{4}-[a-z0-9-]+-\d` matches by accident: a "2025-BYD-Sealion-8-U8055400" scores, a "2021-Mercedes-Benz-CLA-Class" does not. A platform at a clean 0% invites investigation; one limping along at 7% looks like it works.

Patterson Cheney holds 423 live listings. Its /all-stock/sitemap, linked from robots.txt the whole time, lists 1,931 detail URLs. The sweep was already following that file — its child filter matches "stock" — and then discarded every URL in it.

CarsGuide also scores 0% and is deliberately left alone: it is a marketplace crawled through facet pages, not a dealer site with a stock sitemap.

Teach the sitemap sweep to see iMotor detail pages

07:04 am AWST · 422e090

97 sources sat at roughly 13 listings each while their sitemaps listed the whole yard. The sweep had been reporting "sitemap 0" for every one of them, which is indistinguishable from a site that has no sitemap, so they were skipped and nobody looked again.

Two filters were wrong in opposite directions. VEHICLE_PATH expects the vehicle-ish segment followed by one more segment and the end of the path, so /new-cars/for-sale/chery/tiggo-7/2025/urban/t32-my26/3841785/ — seven segments of make, model, year, badge, series — matched nothing. BROCHURE_PATH then excludes everything under /new-cars/, which is the prefix iMotor uses for genuine new stock, so a widened VEHICLE_PATH alone would have discarded the new-car half as brochures.

IMOTOR_DETAIL requires both a /for-sale/ segment and a numeric stock id at the end, which the model brochures BROCHURE_PATH exists to catch have neither of, and is tested before the brochure rule. /new-cars/tiggo-7/ is still a brochure.

Measured, not assumed: Ipswich Chery holds 13, its sitemap lists 129, and its own search index reports nbHits 129. Frankston Kia 11 against 222, Dubbo Hyundai 9 against 57. First six sources of the full run: three gained 107, 261 and 366; the three already well covered gained nothing, which is the result that makes the other three believable.

Also fixes the same swallowed-error pattern in the source query. 119 ids built an id=in.(…) query string long enough to be rejected, and only `data` was destructured, so the rejection arrived as an empty list and the script printed "no sources matched" — which reads exactly like ids that matched nothing. One id worked, so the obvious conclusion was that the ids were wrong. The query is now chunked, the error is thrown, and ids that genuinely do not resolve are named rather than counted.

Correct the source count: 672 dealers, not 675 of 682

07:03 am AWST · 31b7c15

The refresh an hour ago said "675 dealer sources holding stock of 682 enabled". Both numbers were counts of the wrong thing. The 682 is every enabled row in `source`, which includes the eight government datasets and the two marketplaces; calling those dealer sources inflates the figure with things that are not dealers. Broken out by kind: 672 dealer_site, 2 marketplace, 8 gov_dataset.

The right claim is stronger anyway — all 672 dealer sources currently hold stock, so the qualifier "holding stock" was carrying no weight.

Catch a make the feed split, and a caravan carrying no body type

07:02 am AWST · 80d5c16

Five aliases sat in the review queue and none needed a human.

"western star" has been excluded since trucks were, and a Western Star still matched nothing. Same failure as the "Avida," punctuation bug — the list was right and the key never reached it. isNonCarMake now optionally takes the model and tests the joined string against the same list, so it can add a match but never invent one.

A caravan arrived as make "Empire", model "Luxor", badge "CARAVAN" and no body type at all, so the body-type list was consulted against an empty field. Badges are now checked against their own small vocabulary rather than reusing NON_CAR_BODY_TYPES, because that list holds "cruiser" for motorcycles and a feed putting "Cruiser" in a LandCruiser's badge would have dropped a real car. Only words that are never a trim level are in it.

Bad Boy Mowers are mowers.

The model is threaded through recanonicalise and check-integrity too, so the sweep and the invariant agree with ingest instead of disagreeing quietly — an out-of-scope row the integrity check cannot re-derive is exactly the orphan that check exists to catch. Queue is empty: 4 vehicles swept, 3 aliases marked.

Keep what each insight said, so its stability can be measured

06:46 am AWST · fd6d87e

Entry 13 recorded an insight that changed its winner between runs — gold, gold, then orange — and deferred the fix as a judgement about what to show. This is not that fix. It is the prerequisite, which turned out to be missing: nothing in the database could answer "has this insight named the same winner twice?"

compute-insights deletes every market_insight row and re-inserts. That is right for the live surface, since an insight that stopped clearing the bar must not linger because it once did, and it destroys the claim on every run. market_insight_run recorded that 1 of 4 passed but never which, so the only record of gold becoming orange was a human reading the logs at each step.

market_insight_history is append-only, consistent with observations: an insight's history survives its own retraction. Verified by running compute-insights for real rather than seeding a test row — two readings so far, orange both times, which is one repeat and not yet stability.

Also records why the migration file carries no header comment. It was applied through the API with the prose trimmed, and sync-migrations regenerates files byte-exact from what the database stored, so the header could not survive. Putting it back by rewriting the recorded SQL would make the tree prettier and the audit trail false. The reasoning sits in entry 13, which the table's own comment points at, and OPERATIONS now warns the next person to apply the whole file.

Explain the freshness clock reading older than its own window

06:46 am AWST · 75a7643

The panels revalidate every 60 seconds and a visitor can still land on one saying "9m ago", which reads as a broken timer and was reported as one.

ISR regenerates on request, not on a timer: when the window expires nothing happens, and the next request is served the stale copy while the rebuild runs behind it. With no traffic there is no next request, so the page ages until someone arrives — and that person is always the one who sees the old number.

Measured on production rather than asserted. Four requests, no other change: age 365 HIT, then 15, 19, 22, 25. The first request paid for the rebuild and the second collected it, which is exactly what the 30-second AutoRefresh tick does for a tab left open.

Recorded so the next person to notice it can stop in one paragraph instead of going looking for the bug.

Refresh the corpus figures, including the two that went down

06:46 am AWST · 164e9af

The docs claimed 77,300 listings across 635 sources. The database says 130,991 live of 132,657 seen, across 675 sources holding stock of 682 enabled, with 357,912 observations, 3,096 price changes and 1,666 delistings. Being 42% adrift understated the product, but a number the database disagrees with is wrong in the direction that matters regardless of which way it leans.

Two figures fell and both are recorded rather than quietly dropped. Makes went 112 to 87 because the taxonomy sweep reclassified caravan, camper and truck brands that were never cars; a make count that only ever rises is one nobody is checking. Field coverage slipped a point or two across state, fuel, transmission and body because the sources added are small franchise sites publishing less per listing than the large yards already in the corpus.

DEMO also now separates "delisted" from "sold". A listing leaving a dealer's site is evidence the car is gone, not proof it was sold — 172 of them came back under a new URL. The pages say sold; the doc says delisted and states the gap, which is the honest width of the claim.

Every figure carries the timestamp it was taken at, so the next reader can see how old it is instead of trusting it.

Entry 13: an insight that kept changing its mind

06:46 am AWST · 4859ac9

Over one night, as the corpus went from 50,000 listings to 122,000, the colour-premium insight said gold at 10% (p=0.0478), then gold at 7% (p=0.0244), then orange at 7% (p<0.001). The regional insight went QLD, then NSW, then failed to clear the bar at all.

The p-values improved while the subject changed, which is the part worth naming. adjustForSelection corrects for choosing an extreme from many candidates — it is the right correction for *whether* the extreme is real and says nothing about *which* one. The argmax of thirteen noisy colour groups moves as data arrives; gold to orange is a different claim, not a sharper one.

It matters more than a wrong number because "gold cars command a premium" is a line a dealer repeats, and a strong p-value on the winner makes it read more trustworthy rather than less.

Deliberately recorded without a code change. The fix is a judgement about what to show — require the same winner across several computations, report the distribution instead of the extreme, or keep only pre-specified comparisons — and the most honest option is the least interesting to read, which is the actual tension. Until it is decided, the caveat stands: an insight naming one winner from many candidates is evidence of an effect size, not a fact about that winner.

"Avida," did not match "avida", and seventeen caravan brands

06:46 am AWST · 3da6600

The review queue filled to 22 overnight, almost all from one dealer that sells caravans beside its cars. Each arrived with a body type already excluded — caravan, pop top, hybrid — so no vehicle was ever in scope; only the make was unrecognised.

One of them exposed a real bug rather than a missing name. "Avida," with a trailing comma failed to match the "avida" already in the exclusion list, because make keys were normalised for case and whitespace but not punctuation. Any feed emitting a stray comma or full stop bypassed the entire taxonomy — the exclusion lists, the aliases, the canonical makes.

Fixed at the key, so it benefits every lookup rather than that one entry. Only leading and trailing punctuation is stripped, and the character class is explicit rather than [^a-z0-9]: internal characters carry meaning in "Mercedes-Benz", and a blanket strip would eat the leading letter of "škoda", which is a real alias in the list. Both verified, along with " Toyota. " resolving to Toyota.

Seventeen caravan and camper brands added, plus Mitsubishi Fuso as a bus and truck division named in full.

Elite, Empire and Western are deliberately not added. Each is a real caravan or truck brand here, and each is a generic enough word that a car marque could plausibly use it — the same reasoning that kept Golf out. They stay in the queue for an individual decision, which is what a queue is for.

Newsletters are not listings either

06:46 am AWST · 6c04da2

Cargaz spent a fetch on /e-news/winter-2026-e-news-edition/4171/ — a newsletter, correctly parsed to null. Same class as the press releases excluded earlier: a dated article URL supplies the four digits the listing matcher wants, and "e-news" was a spelling the earlier fix did not cover.

Adds /e-news/, /enews/ and /newsletter/. Checked before adding, as with the

Add a monthly donation alongside the one-off

06:46 am AWST · be69f5f

Stripe refuses a donor-chosen amount on a recurring price outright: "you may only specify one of custom_unit_amount, recurring". So monthly is $5 a month per unit with the quantity adjustable at checkout, which still lets the donor set their own level. The page says so instead of implying the two flows behave the same way.

One-off leads as the filled yellow button because it is the lower friction ask; monthly sits beside it as an outline so the pair reads as an option rather than a decision to agonise over. Both are hosted Stripe pages, so wallets work and the app still holds no Stripe credential.

Cards and wallets only, Managed Payments disabled, SPOTLOT on the statement via the shared product, and a confirmation that mentions cancelling any time.

Remove the embedded checkout; the hosted page is the better donation

06:46 am AWST · 41bfe37

Taj called it, and the deciding reason is Apple Pay. Wallets only appear where the hosting domain is registered with Stripe as a payment method domain, so an embedded form loses them while the hosted page has them by default. For a donation, one tap almost certainly beats typing a card number, and that outweighs staying on the domain.

The rest agrees. The embed cost two npm dependencies, a server action, a client component and a live secret key in the environment, and it took four version-specific failures to get working. The hosted link is one public URL in an env var. Now the Spotlot Stripe account is themed, the hosted page is dark with a yellow button carrying black, so it looks more like Spotlot than the embed did with a white form card in a dark panel.

Both Stripe keys are deleted from Vercel and the placeholders from .env.local. This application now holds no Stripe credential of any kind: the entire integration is a URL.

Embed the donation form in /support

06:46 am AWST · 77ea3e3

Four failures got us here, each only visible once the one before it was fixed, because Stripe validates in order and none of it could run without a live key:

1. Managed Payments is on by default on the new account and cannot collect donations. It refused custom_text here exactly as it refused submit_type "donate" on the payment link. Disabled per request, which also keeps both donation paths identical with Spotlot as merchant of record. 2. ui_mode "embedded" is retired on this account's API version. 3. ui_mode "form", which the newer docs describe, is not valid either. This account accepts hosted_page, embedded_page or elements. 4. The session was requested inside a useState initialiser, so it ran during render. Next refuses that ("Server Functions cannot be called during initial render") and the deploy failed at prerender. It now runs from Stripe's own fetchClientSecret callback after mount, which also keeps /support static.

The local build could not have caught the fourth, because without the Stripe keys the embed branch never rendered. web/.env.local now carries obvious placeholder keys so a local build exercises the same path.

The container carries a minimum height. The frame measures itself and posts a height back, so a container sizing to its content gives it nothing to grow into and collapses it to zero, which is what the first working session rendered as.

A typo, a forklift and five caravans walk into the review queue

06:46 am AWST · 2bd7dbd

Eight aliases arrived from the newest sources and they split three ways, which is the reason the queue exists rather than a rule.

Six are out of scope: Apache, Ever Green, Villa Caravans, Viscount and Winnebago Industries are caravans and RVs, and a Komatsu forklift came through a dealer's feed alongside its used stock.

One is the opposite failure. "HYUNDI GETZ" is a dealer's misspelling of Hyundai, and left unmapped it drops real Hyundais out of the corpus entirely — the same queue, the same shape, the exact inverse of what to do about it. Mapped to Hyundai, along with "hyundaii".

One is a make we were missing. ZX Auto is a Chinese ute maker; its Grand Tiger arrived with body type "c/chas", which is a vehicle a dealer trades. Added as canonical rather than excluded.

Verified all three groups plus controls: HYUNDI and ZX AUTO resolve to real makes, the six caravans and the forklift are out, and Toyota, Isuzu and Golf are untouched.

Banner: the contour dome, from Taj's reference

06:46 am AWST · ef05629

Replaces the trend line with the reference Taj uploaded — a stack of nested wavy lines forming a hill.

The whole character comes from one move: every line is the same ellipse shifted down by a fixed spacing and clipped to the canvas. Along the steep left flank each lower line exits the bottom edge slightly further right, which is the fan; and the lowest lines survive only as short arcs near the peak, because the rest of their ellipse is already off-canvas. Undulation phases drift slowly with the line index instead of being independent, so contours stay roughly parallel the way real ones do — independent phases made neighbours cross, which read as noise. Seeded, never Math.random(), so the asset is reproducible.

Colour departs from the reference deliberately. The reference is white on black; here the stack recedes into the black and exactly one line is spot-yellow-hot. That is BRAND.md §5 — yellow is the subject, grey is everything else — the product's own chart grammar as ornament, and the reason it reads as Spotlot's rather than as a generic contour pattern. An all-yellow stack was rendered and rejected: no subject, and BRAND.md warns acid yellow at scale is fatiguing.

The cover now carries no type. The reference has none, and the company page prints the name and tagline directly beneath it, so a tagline here is the same sentence twice within 100px — the argument that already keeps the wordmark off. Type over the dome was rendered and the lines run through the letters.

The personal banner takes the dome re-proportioned, not scaled: 4:1 against the cover's 6:1, and it still has to carry mark, wordmark and tagline, so the dome sits low as a horizon under the type.

check-brand-layout.ts rewritten for a design with no type on the cover. It earned itself twice: the dome's left flank was calculated to clear the logo tile and rendered 9px inside it, and the first version of the check measured a gap between two contour lines rather than above the dome, because at 2x the lines resolve as separate row-bands.

Em dashes out of the copy, everywhere a reader sees one

06:46 am AWST · db4239c

62 rewrites across 14 files, each given the punctuation its sentence actually wanted rather than a blanket swap: full stops where two statements were joined, colons where a list or restatement follows, commas for asides, and a middle dot in the label-and-value strings the valuation prints ("same badge (XLT) · weight ×1.50"). A few sentences were restructured instead, where neither a stop nor a comma read well.

Scope is what a reader sees: page copy, valuation workings and adjustment details, insight narratives, the freshness tooltips, the recall and ANCAP lines, page titles and the share card. Code comments are left alone.

Two kinds of em dash deliberately survive. The 44 bare "—" cells that stand for a missing value in a table are a typographic null marker rather than punctuation in a sentence, and every alternative reads worse. And sold.ts matches a literal em dash inside its title regex, where changing the character would stop it recognising sold cars.

One stored insight row still carried the old wording, since the narrative is persisted when the engine runs rather than rendered from source each time. Its punctuation is updated in place to match what the fixed code now generates; no figure was touched.

Support Us: one pill, one page, and the form on the page

06:46 am AWST · 035fc1e

Four changes, all Taj's calls:

The nav pill reads Support Us rather than Support, which could be read as a help desk, and it no longer rides at the end of the wrapped links row on a phone, where it landed wherever the wrap left it and looked like it was floating. On mobile it now shares the first row with the wordmark, pinned right; on desktop it sits inline after the links.

The panel under a delivered valuation is gone. The ask lives in the nav and the footer, and the valuation page goes back to being only about the car.

/support is tighter: the ask and the form share the opening panel instead of a tall block of text over a duplicate call to action at the bottom, and the duplicate is deleted.

Embedded Checkout is built and activates the moment a Spotlot key pair is set: the form renders inside /support with redirect_on_completion never, so a donor pays without leaving the site. The secret key is read only inside the server action, never exported and never bundled; if a session cannot be created the component falls back to the hosted donate link rather than showing a broken form. Unconfigured, as now, the page shows the link button exactly as before.

Em dashes are out of the donation copy, the page titles, the share card alt text and the Stripe confirmation message. Roughly 120 remain elsewhere in the app and want a careful pass rather than a blind find-and-replace.

Free means donation-funded, and the carve-out was wrong

06:46 am AWST · 63a77ea

Taj confirmed the scoping is right and settled the funding model: Spotlot runs purely on donations, with no intention of ever changing that.

So the reserved right to charge for "automated access at volume" is gone. Beyond the funding decision it was already wrong — /support has been live promising "A free API. Open to anyone who wants to build on top of it", so the About was contradicting a shipped page.

"There is no payment mechanism in the product" also goes. It was true when written and is now false: DONATE_URL is set in production and the Chip in button renders on /support today, verified against the live site rather than assumed from the code.

The mission section now says what funds it — donations, not users — and what it will never become: no paywall, no advertising, data not for sale, API free to build on, everything readable whether or not anyone donates. That language is deliberately aligned with /support, which is the canonical statement of the promise; the file now records that the two must not drift.

Wording is "there is no intention to change that" rather than a guarantee. Not hedging: it is the strongest claim that is actually true, since a company can state its intention but cannot bind a future it does not control. The sign-off note records the decision and its date.

Personal About picks up the same commitment, since that profile is where most of the early reach lands.

38 more from a re-mine — 672 sources, and the seam is thinning

06:46 am AWST · c452103

The sitemap sweep changed what there was to mine. 270 sources went from publishing one index page to publishing their whole yard, which put 20,825 new captures on disk — different pages, not the same ones re-read — and a mine over the newest 25,000 found 366 candidate hosts against 304 last time.

The yield is the honest headline: 38 of 244 that resolve, 15.6%, against 36% and 38% on the two sweeps before it. Recorded so the next person does not re-run this expecting the earlier rate. Discovery is no longer where the listings are; depth on known dealers is.

cars4us.com.au is worth its own line. It was surveyed hours earlier and parked — 884 vehicles, crawler-friendly robots, the whole inventory in one fetch, but no structured data any extractor could read. It probes clean now because the JSON-LD extractor learned to read Product nodes in the meantime. A fix made for eight broken sources quietly unblocked a ninth nobody was looking at, which is an argument for re-probing the parked list whenever an extractor changes.

Platform carried per host: 33 jsonld, 5 easycars.

Company About: what we do, why we exist, and why it stays free

06:46 am AWST · 00065bb

Restructures the company About to the three movements Taj asked for. Five rewrites were drafted independently and scored on three lenses — voice, adversarial honesty, and whether a dealer would follow the page. Voice and persuasion both picked the trade-voiced draft; the honesty pass picked a different one as the only draft it could not find a six-month embarrassment in. This ships the honest base with the best surviving lines grafted in, and drops what the honesty pass killed: "no account gate" (contradicted by our own bearer-key API two sentences later), "nobody has been keeping the record" (falsifiable, and rebutted by SPEC.md naming paid valuation providers), and a cost-base joke that stops being true with scale.

The free commitment is scoped rather than unconditional, and the file now records why. An absolute pledge was drafted and rejected: the product already ships a rate-limited bearer-key API, so "free forever, all of it" would be broken by the most predictable thing that could happen. What is promised instead is the record, the valuations, and never charging for the evidence behind a figure — with volume API access named out loud as the reserved exception. "There is no payment mechanism in the product" carries the weight as a present-tense checkable fact.

Still no figures, for the same reason the banners carry none, and now with a second reason: the row counts are large but observation began on 10 August 2026 and the history is days deep. "The record is young and deepens every day" is true now and stays true; "a day not recorded is gone" makes the youth the argument rather than the thing to hide.

Short variant corrected — it said listings sell, which the About itself says no public source confirms.

A Support pill in the nav

06:46 am AWST · 517a71b

An action rather than a nav item, so it reads as a pill — yellow carrying black, which is the one job yellow has. It points at /support, not straight at Stripe: someone should meet the context before the payment form. Hidden with every other donation surface when NEXT_PUBLIC_DONATE_URL is unset.

The ask stops arguing: no costs, no scarcity, just an open door

06:46 am AWST · 149b81c

Taj cut the cost transparency, and the reasoning is better than the framing it replaced. A bill on screen argues the tool is expensive — it isn't — and 'if the money stops, the corpus stops growing' is a threat wearing transparency's clothes. Neither was needed to ask.

So the whole financial case goes: no dollar figures, no runway, no consequence-if-you-don't. What remains is a statement and an offer — free to use, no ads, no accounts, no paywall, data not for sale; if you find it useful and would like to chip in, anything helps. The old scarcity line is now its own reassurance: everything stays readable whether or not anyone ever donates.

Same three surfaces, same restraint — footer line, /support, one line under a delivered valuation — all still dark until NEXT_PUBLIC_DONATE_URL is set. Unset, the footer states what the product is and links nowhere.

The donation surfaces, dark until there is a link to point them at

06:46 am AWST · 3f228c5

Three places, chosen so the ask is felt once and never nags: a footer line sitewide, a /support page carrying the actual bill, and one quiet line under a delivered valuation — the moment the tool has just been useful. No banner, no modal, no dismissal to out-wait.

The link is configuration, not code: NEXT_PUBLIC_DONATE_URL is a public Stripe Payment Link, so no key, webhook or secret enters this repository — which is also how a Dealerloop-owned Stripe account can be merchant of record without touching the boundary. Unset, as now, the footer offers the costs instead of an ask, the valuation line does not render at all, and /support says plainly that donations are not open yet. Nothing half-built ships.

The bill on screen is the verified figure only — US$10/month for the database, with the corpus size beside it — and says outright that hosting and the domain carry no number because neither is confirmed. Inventing one on a page about honesty would be the worst place to guess.

Personal profile copy, and the part of it I refused to write

06:46 am AWST · eefa6a6

Adds Taj's LinkedIn profile: headline (three options), About, the Spotlot experience entry, and skills. Kept deliberately free of sentences reused from the company page, since anyone reaching the company page from the profile reads both within a minute.

Every technical claim in the experience entry was checked against the source and the schema before being written: the SSRF guard, robots and Crawl-delay handling and per-host serialisation in crawl/fetcher.ts, the honest SpotlotBot UA and its "a 403 is recorded, not fought" rule, raw_capture retention, the taxonomy review status, and the valuation table's comparables, adjustments, confidence, days_to_turn_estimate, buy_price_recommendation and model_version columns. The API is described as versioned, authenticated and rate limited because route.ts returns 429 on the limit, not because SPEC.md says it should.

The About dates the start of recording to 10 August 2026 rather than staying vague about how long the record runs. Three days is not much, but implying more is the same failure as implying coverage, and a stated date becomes a real track record later.

No career history: I do not have it and will not invent it. The file ends with exactly what is needed to fill that gap, and flags the absence of any motor-trade credential as the biggest hole in the copy.

check-brand-copy.ts now covers all five length-limited fields.

Banner: a trend line that waves before it climbs

06:46 am AWST · 2dfc342

Taj asked for a yellow line squiggling up from the centre to just below the top-right corner, wave-like, with a dot on it. The banner's geometric centre sits inside the tagline, so the composition had to move — six treatments were designed independently and rendered before choosing.

Five of them bought room for the curve by damaging the type: tagline down to 40-48px, left-aligned, or the subline dropped. None of that was asked for. The one that ships leaves the type exactly as it was and pays for the corridor with 13px of extra lift.

The wave is load-bearing, not ornamental fussing. A line that only ever rises is the "line goes up" gesture, and on a brand whose whole claim is that it does not fabricate data that is worth avoiding even in ornament, so it moves the way a market moves and eases into the terminus. No axis, no gridlines, no ticks: it plots nothing and must not be readable as a claim about a quantity. The dot is the mark's own dot at another scale.

The personal banner gets the same device but not the same path — that canvas is 4:1 against the cover's 6:1, and the proportionally-scaled curve reached the corner as a hockey stick.

Banner rendering moves to lib/banner.ts driven by a spec, so treatments render through the same code path that produces the shipped asset; verified byte-identical on the unchanged assets before any curve landed. Both banners also render at 2x, since LinkedIn lays the cover out at 1128 CSS px and the spec-size file upscales on retina.

check-brand-layout.ts measures the rendered pixels and fails if the line comes within 15px of the type, enters the logo corner, or touches an edge. Earned: the personal banner's first curve was calculated to clear the subline and rendered with 5px between them.

Social assets: the mark that already ships, on both platforms

06:46 am AWST · eb66c59

LinkedIn and Instagram need a logo, a banner and words. All three are generated from one script rather than drawn once, so the tagline and the mark have a single source and every asset follows a change in one pass.

The mark is the one in icon.svg, the header and the share card, unchanged — BRAND.md leaves it open but code settled it, and a favicon that differs from the avatar is not an identity. The platforms take one side each of BRAND.md's inversion: black field for LinkedIn, yellow for Instagram.

No corner radius is baked in, because both platforms mask what you give them and a baked radius peeks outside theirs. The mark centres on its painted ink rather than the 48-grid — round caps and the dot push the extent off-centre, which 48px forgives and 1080px does not.

No figures on the banners. A banner sits for months, so a count printed on it is a stale count wearing confidence; the same reason they came off the share card, and it proved itself here — listings moved 96,016 to 97,421 while these were being built. brand-figures.ts prints dated counts for posts instead, and names any context table that is empty so a capability the corpus lacks never reaches a caption. vehicle_recall has zero rows and is called out in the copy as unclaimable.

proof-sheet.png renders every asset at the size each platform actually paints it: the mark holds at 32px, Instagram's circular crop clears it, and the page logo misses the banner's type.

A field named litres that holds cubic centimetres

06:46 am AWST · eaa26c9

Five TWG Cars listings failed with "vehicle insert failed: numeric field overflow" and were lost. The cause is a lying field name: Dealer Studio publishes engine_size_litres = 3283 for a 3.3-litre Mazda CX-90, and the real value sits in enginesize_litres = 3.3 immediately beside it. engine_litres is numeric(4,1), so 3283 does not fit and the whole vehicle insert failed — the listing was not partially stored, it was not stored at all.

The extractor read the mis-named field directly and had the correct one only as a `??=` fallback, which never fired because the first assignment had already succeeded.

The threshold for "this must be cubic centimetres" is 100, not 10, and that distinction is the interesting part. An Iveco S-Way at 12.9 litres and a Hino 700 Series at 12.0 are both in the corpus and both correct — heavy truck engines really are that size. A rule that flagged anything over 10 would have corrupted two real values while fixing one. Nothing on a dealer's lot has a hundred-litre engine.

Audited the stored data rather than assuming: 52,865 of 52,868 vehicles with engine data are in the plausible 0.5-10L band, the two truck values above are right, and one was genuinely wrong — a Kawasaki KLR650 recorded as 651 litres rather than 0.651. Corrected.

Four camper brands out of scope, and one that deliberately stays in

06:46 am AWST · d86295c

The review queue held five aliases whose vehicles were already excluded by body type — "caravan" and "1 axle" — but whose makes were unrecognised, so each sat pending for a human who would only have agreed with the body type. Jawa, Cub, MDC and MAN join the caravan and truck lists.

Golf does not, and that is the point of the commit. A Golf Caravans Savannah Maxxi 501 is in the corpus and is certainly not a car, but "Golf" is also exactly what a mis-parsed Volkswagen Golf would arrive as — make "Golf", model "GTI" — and excluding the make would drop those silently forever. There are no such rows today, which is precisely when the mistake would be invisible.

So that one is marked ignored as an individual alias with the reasoning attached, which is what a review queue is for. Verified after: Jawa, Cub, MDC and MAN are out; Golf, Volkswagen and Toyota are still cars.

Queue is empty — 42,671 mapped, 30 ignored, 0 pending.

Twenty failures in a row is an outage, not twenty bad sources

06:46 am AWST · 61464c9

A laptop slept for thirty minutes mid-pass and woke with a poisoned connection pool. Every source then failed on its crawl_run insert, and the pass churned through 615 of them in minutes without fetching a single page — burning the queue rather than crawling it.

That is the fix I made this morning overshooting. Before it, one transient error killed the whole pass, which was wrong. After it, nothing could stop a pass, which is wrong in the other direction and cost more: a pass that dies leaves its queue intact for the next one, and a pass that churns leaves 615 sources marked as attempted.

Consecutive failures are now counted apart from total failures, because they mean different things. One source failing is that source's problem and the pass carries on. Twenty in a row is one problem, not twenty, and continuing cannot help — so the pass stops and says how many sources it is leaving. Never-crawled-first ordering means the next pass resumes exactly there.

Migrations: sync the concurrent sessions' files to the DB order of record

06:46 am AWST · e63cb4f

The 80k wave: last per-request scans move behind the refresh

06:46 am AWST · 823661e

The corpus grew 68% in a day and everything that still computed per request crossed the line at once: the /value catalogue (a full scan shipped inside every render), model_ticker's slug pass (regex over every vehicle row per view), model_movers' live-count scan, vin_two_prices and demo_gap. All now read boards written by the 6-hourly refresh (0073-0075); movers' event join drops the view entirely — it only ever read make and model, so it joins listing and vehicle directly. vehicle gains the (make, model) index every model-scoped read had been doing without.

The refresh itself had outgrown pg_cron's 2-minute role timeout — every tick died at exactly 00:02:00 and the retry loop was itself load — so the job now sets a 15-minute ceiling in its own session (0076), leaving the role default alone.

And a page must never owe its life to one panel again: home and market fetches degrade per-panel (tolerant/soft, logged, healed on next revalidation) — both pages are ISR, so before this a single statement timeout at build time could kill the deploy.

Concurrency 16 to 24: the backlog changed shape, as predicted

06:46 am AWST · 3f9e1cc

The threshold was written down before the data arrived, precisely so this could not become a post-hoc justification: below 40% of the backlog in sources holding 1,000+ listings, raise to 24-32; above it, the answer is hours not slots.

The sitemap sweep settled it. 270 sources now discover through their own sitemap, 43,670 listings became reachable that were not before, and only 11% of that sits in sources gaining over a thousand — the median source gained 162. Against the pre-existing 29,574, the combined backlog is ~73,000 listings and about 32% head-heavy. Broad, which is the case extra slots help; per-host politeness makes the head sequential no matter how many slots there are.

24 rather than 32, deliberately. The constraint that replaces politeness is database write throughput, and that has not been measured. If a pass at 24 stays clean, 32 is the next step.

Worth being clear about what the sweep found, because "43,670 more listings" sounds like more dealers and is not. These are dealers already in the corpus whose single index page renders 21 cars while their sitemap lists the whole yard. Patterson Cheney Group: 18 held, 3,668 available. Werribee Automotive against about 530. We were not missing dealers. We were seeing 4% of the ones we had.

Trade-in anchors on the median; the counter learns to mean people

06:46 am AWST · db92013

Valuation v0.2.0: the buy price starts from the weighted median, takes the 10% margin explicitly, and prices uncertainty explicitly — a discount of a quarter of the range width, capped at 5% — with v0.1's low-anchored figure kept as a floor so nothing is ever less conservative than before. v0.1 stacked prudence three deep and collapsed on wide cohorts: the 2025 Q5 that recommended $54,000 against an $88,888 mid now recommends $68,000 against $79,990, workings on the page. Tight cohorts barely move, which is the property that matters.

The counter: ua_class is recorded at the request layer (0064) — a shared link makes WhatsApp and friends run the valuation like any visitor, and they were indistinguishable — and the exclusion is web-only (0067), because a curl UA on the key-gated API path is a customer, not a scraper. The build hour's own 40 test requests are marked internal; ?internal=1 now runs the full arithmetic with no footprint at all, verified: a dry run wrote zero rows while a WhatsApp-UA fetch wrote exactly one, classed bot, excluded from the count. 239 served · 151 distinct cars, from 280 raw.

And /value shows it: Valued recently — subject, range, when; never who — with repeats collapsed and each row linking back to a live re-run of that valuation.