Method · Changelog · 18 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.

Tuesday 18 August 2026 · 36 changes

The pilot doc records what the panels measured

10:32 pm AWST · 6718bf3

Including the two things that were measured and then refused: a body-type comparison the yards' vocabularies cannot support, and the publisher's own price-drop flag.

The yard learns when each side starts cutting

10:28 pm AWST · 1e80b20

Six panels of comparison against the yards this dealer actually bids against, all from one cohort defined once in Postgres so no panel can quietly redefine it. Three corrections are baked into that definition: the dealer is excluded from their own comparison (they are 14% of dated WA stock, and leaving them in is how a large yard proves it is average), used only, and it is never called "the WA market" because 52 of the 96 WA yards publish no listing date and cannot be timed at all.

The panel that justifies the rest is price-cut timing. On stock under a month old this yard moves the price on 1.4% of cars while the field moves 21.7% of theirs, and by 180 days the two converge to 15.3% against 19.5%. That number is in no feed and no publisher reports it: it exists only as the difference between two observations of the same listing on the same dealer's own site. Two rules hold it up. One definition of a cut on both sides, ours, since publishers count their own "price dropped" flags over a whole listing life and mixing them inflates the young-stock gap about fivefold. And the visit rate is printed rather than assumed, because a cut is something we have to catch and comparing a weekly crawl against a daily one would manufacture the entire finding.

Body type gets no field line, and that absence is the point. The same six SUV models are filed as "wagon" by 40 yards and "suv" by 37, and utes are split across four spellings, so a join on body_type would have told this dealer his 616 utes face 127 rivals when the field holds about 1,436. That is the same class of error as the valuation that invented a badge. Normalising the synonyms was the tempting fix and is refused: "wagon" genuinely means both an estate and an SUV depending on who typed it, and a guess buried in a mapping table is far harder to notice than a missing panel. Fuel keeps its comparison, because five values cover 99.0% of his listings and 99.2% of the field's, spelled the same on both sides.

Standing stock is counted in his own asking prices and never called no date sit in their own band rather than being folded in as new.

Charts reuse the existing ChartInteraction, so hover, crosshair readout and keyboard traversal cost no additional client JS.

Kiel set two days and it did nothing

09:33 pm AWST · c8bc566

He chose a two-day window, which should count MORE cars as gone. The panel read 0 counted gone against 89 too soon, and the legend printed the reason in plain nonsense: "Probably gone: 4 to 2 days".

The tiers assumed the dealer's threshold sat ABOVE the measured lower band. Under 4 days was "too soon", under the threshold was "probably gone", beyond it was "counted gone". At a threshold of 2 the middle test can never fire, because anything under 2 is already under 4, so every exit fell into the first bucket and the setting inverted itself.

The lower boundary is now capped at whatever the dealer chose. Pick a window shorter than our measurement and the middle band has no room and disappears, which is the honest shape of that choice: they have said two days is enough, so there is no "probably" left to express. The panel shows two tiers instead of a permanent zero.

Same data after: 12 counted gone, 77 too soon.

next_presumed_at now has to be in the future too. It was the earliest crossing date among cars not yet counted, which on a short threshold is a date in the past, so the page advertised a milestone from last week.

Also removed the line under the yard figures about the public page showing most of them to anyone. The OBSERVED and YOURS labels on each figure already carry that.

Every dealer page was 500ing, and the build said it was fine

09:28 pm AWST · d53a527

~990 public pages down: "Page changed from static to dynamic at runtime,

The header needed to know who was signed in, so the LAYOUT called currentMembership(), which reads cookies. A layout that reads cookies opts every page beneath it out of static rendering, and /dealer/[slug] is prerendered per dealer. Prerendered at build, dynamic at request, 500 for everyone.

WHY IT SHIPPED. The layout wrapped that call in a try/catch so a failure could not take the chrome down with it. That catch swallowed the exact error Next raises to say "this cannot be static". tsc passed, the build passed, the route table still printed the page as SSG, and the only signal was a status code in production. Defensive error handling around a build-time assertion turns a loud failure into a silent one.

The header now fetches /api/me after the static shell is served, so no page under the layout reads cookies. It renders nothing until it knows, rather than guessing "Dealer sign-in" and correcting itself, because a control that changes under the cursor is worse than one that arrives a moment late. Nothing there is a permission: /yard re-checks membership server-side on every request, so a forged response buys a menu item and no data.

Verified against a real production build rather than the dev server, which does not prerender and answered 200 throughout: /dealer/westsideauto and /dealer/scarborotoyota both 200, zero static-to-dynamic errors in the log.

The sign-in sub-copy edit that never landed

09:15 pm AWST · 8d71b9e

The script that was meant to make two changes to AuthShell threw on the second before writing the file, so neither was saved. The rerun only carried the second, and the panel kept explaining what the portal contains instead of what an account is for. Caught by looking at the deployed page rather than at the commit.

Six passes over the dealer screens

09:12 pm AWST · 1baa219

**Sign-in.** The left panel explained what the portal contains in a sentence nobody needed, and closed with a paragraph about what is not behind a sign-in. Both gone. It now says what an account is for: more granular reporting for your yard, plus rego lookup on valuations.

**Yard header.** The provenance paragraph restated what the OBSERVED and YOURS labels on each figure already say. The labels stay, the paragraph goes, and the page reads as numbers rather than as an explanation of numbers.

**Months are clickable.** "Nine Hiluxes" is the answer; "which nine" is the question straight after it, and there was no way to ask it. Every model row now opens the cars behind it with stock number, VIN, badge, last ask, odometer and how long each has been gone, each linking through to its own history. The make and model come from the URL and are attacker-controlled, so they are only ever equality filters inside the membership's own source_id: editing the URL can produce an empty list, never another dealer's cars.

**Exit tiers, renamed.** "Presumed gone / Likely gone / May return" was three shades of one word with no sense of which number to act on. Now "Counted gone / Probably gone / Too soon to say", with the day boundaries stated under them rather than left to be inferred from an adjective.

**Personalised pricing, moved up and reframed.** It was a paragraph asking for data entry, sitting low enough to be missed. It is now near the top and states the distance: "You are 20 valuations away from Spotlot learning how you price", with a padlock whose shackle lifts as the bar fills. The gamification stops short of fiction: the bar is the real count against the real threshold and nothing claims to be learned before there is enough to learn from.

**Listing page.** It had no way out, which for a page reached from four different places is a dead end. A back control now uses history where there is history and a real link where there is not, because a button that silently does nothing is worse than an honest link.

Its chart had two faults. A price that never moved drew a lone flat line marooned in a tall empty box, when the interesting fact is that it did not move, which is a sentence: "Held at $21,977 across 2 observations." And the caption was a <text> node inside a width-scaled viewBox, so it rendered at roughly four times the size of the body text beside it. Captions are HTML now, which is the rule the rest of the site already follows.

Merge pull request #4 from taj-ship-it/dealer-portal

08:40 pm AWST · a00846a

Dealer accounts, rego lookup, and the yard

The rego username is a credential, so stop writing it down

08:37 pm AWST · 6ef905f

For this service the username IS the API key: anyone holding it can spend the balance. It was sitting in the plan document in plain text, which is fine on a laptop and not fine in a remote that may one day be public.

The value lives in REGCHECK_USERNAME and nowhere else. The doc now says how to verify it rather than what it is.

The yard answers the question it was asked

08:11 pm AWST · 7bdb9d8

Audit and rebuild of /yard.

BY MONTH, which is the whole point. Kiel asked how many Hiluxes he sold this month and the page never answered. It does now, by month and by model, and the thing it had to get right is what it refuses to print: a month we were not watching must never render as 0. Zero means "none left your lot"; we mean "we were not there". So only observed months appear, the watch-start date is stated, and a month whose span we did not fully cover is marked a floor rather than a total. Today that is one partial August against a watch that began on the 10th, which is a thin answer and a true one.

The old by-model table inside the exits panel is gone: two panels answering the same question differently is worse than one answering it once.

A DEALER'S OWN THRESHOLD, as a setting. It sits beside the measured calibration rather than replacing it (dealer_exit_pref, 0188), so the page can say "you are using 3 days, Spotlot measures 8" instead of quietly becoming a different product. Verified: at 3 days the same 89 exits go from 0 presumed gone to 12, every figure on the page moves together, and clearing it returns to 8. Bounded 1 to 90; zero is not offered, because at zero every car is gone the moment a crawl misses it and the number counts crawl gaps.

THE EXITS PANEL, cut. It carried a headline, a disclaimer paragraph, three counts, a pending note, a calibration block, a by-model table, a full list, a footnote and a link. Now: three counts, one sentence saying a car leaving is not a car selling, and everything else behind disclosure. The word "sold" still never appears.

PROVENANCE PER FIGURE. The header asserted globally that nothing here is supplied by the dealer. That becomes false the day a feed lands, and a page that must be rewritten to stay honest will one day be wrong. Each figure now carries its own origin.

A REAL BUG, found in the audit: exit rows linked to the seller's own URL, which for a delisted car is precisely the soft 404 that made it hard to detect in the first place. Clicking a car took a buyer to a generic "Used Cars For Sale in Perth" page. They point at /listing/[id] now, which still holds the history.

Every table on the page was min-width and scrolled sideways, which on a phone in a yard is the one interaction nobody can do one-handed. All three are stacked rows now; verified no horizontal overflow at 375px.

Written down rather than built: the rule that supplied data must stay distinguishable from observed data and must not reach public aggregates without a decision, and what a yard-scoped outbound API would need.

Poll an endpoint, do not accept a webhook

08:00 pm AWST · 9d3eec5

A webhook is more work than it looks: auth, replay protection, idempotency, and a dead-letter path for the night their system sends something malformed at 2am. Stock changes a few times a day, so polling is indistinguishable to the user, dramatically simpler to operate, and reuses the crawl chain instead of adding an inbound surface to defend.

Also recorded: a CSV export of twelve months of sales, sent once by hand, makes the monthly reporting real immediately and backfills history the crawl can never recover. Worth asking for alongside the endpoint rather than after it.

And the rule that outlives the shape: supplied rows must stay distinguishable from observed ones forever, because a dealer who can feed prices into market medians can move the numbers every other dealer is judged against.

Days to turn moves beside the price it belongs to

07:51 pm AWST · f7b6bcf

It had a panel of its own and a paragraph beside it, and the panel above it had a wide empty space to the right of the price. Both numbers describe the same car and a buyer reads them together, so the figure now sits in that space and the panel is gone.

The paragraph is gone too, but its two load-bearing facts are not. The count of cars the median is taken across stays on the line, because that is what makes the figure checkable. The ageing caveat moves down to the evidence line, where the comparable counts and model version already live: these days come from the seller's own published listing date rather than from how long we have been watching, and those differ by a great deal. Everything else in that paragraph was prose.

Two things kept rather than dropped in the tidy-up. When there are no delistings for a cohort the slot still says so and still says it will not be guessed, because an absent figure and a figure we refuse to invent look identical if nothing is printed. And the right-alignment is sm and up only: on a phone the row wraps and a right-aligned block floats with a gap beside it, so it goes back to normal alignment there.

Three panels became two above the chart, and the block is shorter than it was with the standalone panel in it.

The variant has to be allowed to extend the model

07:43 pm AWST · 636be17

A 2010 Prado Kakadu was valued as a 2010 LandCruiser Kakadu, which is not a car, and came back as insufficient evidence.

The rego service answers a Prado as family "Landcruiser" with "PRADO" in the variant. Model resolution only ever looked at the family, and the corpus carries both models: Landcruiser (2,319 live) and Landcruiser Prado (1,872). Longest-prefix over the family alone can only ever pick the shorter one, so every Prado in the country resolved to the wrong model, and then the badge matcher went hunting for a Kakadu among LandCruiser trims.

Model matching now considers the family AND the family with the variant appended, longest match winning. Promoting the model fixes the badge for once the model is "Landcruiser Prado" the variant "PRADO KAKADU" reduces to "kakadu" and matches the Kakadu badge directly.

One guard, because longest-wins is not safe on its own. "Prado GXL" exists as a model with a single listing, a parsing artefact rather than a car, and on length alone it would beat "Prado" (29) and trade the cohort for one listing. Models under ten listings are not eligible.

Same car after: model Landcruiser Prado, badge Kakadu, 2010, and a range of $25,365 to $33,115 off 4 comparables of 34 found, where before there was no answer at all.

chooseModel moved into badge-match.ts so it can be tested without a database, and the six cases are in CI against Toyota's real model vocabulary and live counts, including the one-listing artefact.

Stop putting a hole beside the short panel

07:29 pm AWST · e45ea71

Side by side, a one-number panel next to a panel with a form in it always leaves a hole, and items-start only changed its shape: the box ended honestly and left 340px of empty page in the middle of the screen instead.

Both panels are full width now and use that width internally. Days to turn is a strip, the figure at the left and its caveat running across beside it, 122px tall instead of 193 with air under it. What to pay splits into two columns inside the one panel: our number and its workings on the left, the dealer's number on the right behind a rule, 313px instead of 532. The block is shorter than either arrangement it replaces and there is no empty region anywhere in it.

The other option was moving the odometer chart up into that column, and it is every axis label.

Also per Taj:

The button reads "Look up and value", which is what it does. It used to say "Look up", which undersold it as a decode step.

The credit counter is gone from the screen. "110 lookups left" tells a dealer the feature is metered and invites them to ration it, which is the opposite of what we want Kiel doing with it. The cap that matters is still enforced server-side in lookupRego(), and creditsUsed() stays for the ops side, now documented as such rather than as a UI helper.

Our number and yours, in one panel

07:23 pm AWST · f939e48

Three boxes in a two-column grid, so the third wrapped onto its own row and left a dead cell beside it. "Your price" now lives inside "What to pay", which also happens to be where it belongs: our recommendation and the dealer's own number are one conversation, and the field sits directly under the figure a buyer is already reading.

Two consequences, both handled rather than discovered later.

The panel is yellow when a buy price survives and dark when confidence withheld it, so the form takes a `tone`. BRAND.md is absolute that yellow only ever carries black, so on yellow every control inverts: dark translucent fills, black text, and a black button carrying yellow. The form is still offered when the recommendation is withheld, because a dealer's own number is worth recording precisely when ours is not confident enough to give one.

Merging made the right column much taller than the left, which just moved the empty space rather than removing it. The grid is items-start now, so each panel ends at its content: 193px against 532px, with ordinary page below the shorter one instead of a stretched empty box. The copy came down too, since length was half the imbalance.

Two more em dashes, both in strings rather than markup and both missed by the last pass: the record confirmation read "Recorded — 22% under our mid" and now reads "Recorded: 22% under our mid", and two session-expiry errors became sentences.

Say it in sentences, and give the row room to breathe

07:17 pm AWST · cff1aeb

Three things Taj called out on the valuation screen.

SPACING. The dealer rego row sat hard under the corpus paragraph with its label, its explanation and its credit count all touching, so the whole block read as one run of text rather than as a control with a caption. It now has air above and below, the chip and the two labels are separated properly, and the fields match the picker's own sizing so the two rows look related rather than merely adjacent.

EM DASHES, gone from every piece of prose on the page. They were doing the work a full stop should do, and stacked up they made the copy look like one long clause. Each one was rewritten rather than deleted, because an em dash usually joins two clauses and dropping it leaves a run-on: "we keep ours as it is — yours is recorded" became two sentences, "changes — a sale is one" became "changes. A sale is one", and so on across the yard, the auth screens and the vendor brief.

data placeholder rather than punctuation, and a hyphen there reads worse. Fourteen of them, all in tables, none in a sentence.

YOUR PRICE, rewritten to say what it is for. It led with a caveat about outcomes being different numbers, which is true and is not the reason anyone should type in the box. It now says the reason first — Spotlot learns how you price, by make, model and badge, and after enough cars it can say where your own numbers sit on a Hilux or a Prado specifically rather than in general — then the privacy, which is the other thing a dealer wants to know before handing over their margins. The outcome note survives as a footnote under a rule, where a caveat belongs.

The rego path may only say what the picker can say

07:07 pm AWST · 07392cc

Taj priced a LandCruiser Sahara both ways and got about $20,000 apart. The stored valuations show it exactly: badge "LANDCRUISER LC300 SAHARA (4x4)" returned a $122,429 mid, badge "Sahara" returns $138,888. $16,459, on a car where the whole point is being right.

The cause was that the rego path INVENTED a badge — model and variant glued together — and handed it to valueCar() as if it were real. No such badge exists in the corpus, so the weighting had nothing to hold. A badge that matches nothing is worse than no badge, because it is treated as a constraint.

Badges are genuinely nasty here. Toyota LandCruiser carries 33, and "Sahara" alone appears as Sahara ($106,999), Sahara ZX ($123,888), Sahara VDJ200R ($85,482) and Sahara (4X4) ($52,990) — $70,000 over one word. Most of that is generation, and the YEAR already separates generations: inside 2025 the same set is Sahara ($139,988), Sahara ZX ($146,990) and Lc300 Sahara (4X4) ($139,999), about $7,000 apart. So the year does the work and the matcher only has to pick a trim.

The rule is now one sentence: the rego path may only emit values the picker on /value already offers. It reads the same catalogue, keyed the same way, so the two paths cannot disagree about what a car is called. Make, model, badge, transmission, drivetrain and fuel are each snapped to a real option or DROPPED — and dropped is exactly what the manual form sends when a field is left alone, so the worst case degrades to behaviour that was already right.

Three deliberate choices inside the matcher: - Spec words are stripped from both sides. Matching a Sahara to a GXL on "4X4" is the failure being fixed, and spec is passed separately anyway. - Extra trim words are penalised, so a plain SAHARA never becomes a ZX. That is $7,000 in the expensive direction. - Ties break toward the badge with MORE listings, not the more precise one. "VX FJA300R" is unambiguously a 300-series and picking it would cut a 20-listing cohort to 4. Precision that costs evidence is not precision.

Same car, both ways, after: $138,888 and $138,888, high confidence.

Also, per Taj: the rego box was a full-width yellow slab, which made a shortcut look like the main event on a page whose main event is the valuation. It is one quiet row now, above the picker it fills in, with a small DEALER chip saying who it is for.

One site. Signing in unlocks it in place

06:55 pm AWST · 4fdcae8

The separate portal was the wrong shape, and Taj's read of it was right: a login onto a scoped copy of the public dealer page, with the valuation tool somewhere else entirely. Two valuation renderers, two front doors, and more navigation than the product has features.

There is now one site.

THE NAV, in the order asked for: wordmark, links, the pulse counters beside API where they belong with the pages that show the corpus, then the right edge for the two things that are actions — the dealer door, then Support Us. Signed out that door is one quiet link; signed in it is the dealership's name and a three-item menu, because an account does not take you anywhere.

REGO IS NO LONGER A FLOW. It was a three-step page of its own; it is now a box above the make/model picker on /value. Rego, state, odometer, one button, straight into the valuation page that already existed. The picker below fills in behind it, so any field can be tweaked and re-run. Taj said the rego lookup was there "purely to speed up the valuation tool" and it now does exactly that and nothing else. /dealer-portal/appraise is deleted.

/dealer-portal is /yard, reachable from the profile menu, and is what it should always have been: a place to look at your own position, not to work a car. The old path 308s rather than 404s, because a link to it was already sent to a real dealer.

One more casing bug on the way through. The rego service shouts "LEXUS"; the corpus writes "Lexus". valueCar() matches with ilike so the valuation was right, but the redirect carried the shouted spelling and the make and model dropdowns underneath a rendered valuation both read "choose…". The resolver now returns the corpus's own spelling — the most common one it actually holds — rather than ours.

The appraisal dashboard: a plate in, a number out

06:41 pm AWST · 97f0f92

Taj's read of the portal was right — a login onto a scoped copy of the public dealer page, with no valuation and no rego. The features Kiel would actually touch were all in phases I deferred, and I sequenced it that way. This is the tool.

/dealer-portal/appraise: type a rego, get the car, get the market, record what you would pay. Three steps rather than one form, because each depends on the last and one of them costs money. The odometer is its own step — a plate cannot supply it, it is the largest input after the model, and a valuation run without it quietly answers a different question.

TWO BUGS FOUND BY EXERCISING IT, both of which looked like something else:

- Zero comparables on an ordinary Lexus. It read as thin data and was a vocabulary mismatch: the rego service answers `family: "RX350h"` while the corpus canonicalises that car to model "RX" — 391 listings, 130 recent — and keeps the rest in the badge. taxonomy-resolve.ts now maps through taxonomy_alias first, then by longest canonical model prefixing the family, derived from the corpus's own live vocabulary rather than a hardcoded rule. 0 comparables became 6, and the car values at $89,990 with $77,850 to pay. - Recording a price blanked the screen. useActionState gives each action its own `prev`, so the save's prev was null rather than the valuation before it, and `saved ?? valued ?? decoded` discarded the car. The row saved correctly every time; the page just threw it away. The three states now layer.

Credits are handled as money, not as a rate limit. Cached forever on (plate, state) so a repeat lookup never re-bills, attributed to a user, a 25/day cap, and credits_spent as a column so a cache hit records 0 and the ledger stays honest. The raw payload is kept whole so a better parser can revisit it without paying again.

scripts/test-regcheck-parse.ts runs in CI against a real captured response, because the payload calls an NVIC a VIN and calls a hybrid petrol, and both mistakes are expensive: the credit, then a confident valuation of a different car.

Verified end to end against a seeded cache entry — no credits spent, balance still 110.

An account with nowhere to go should not be invisible

06:26 pm AWST · 9d5e622

Kiel's account was created outside approve-dealer.ts, so no membership row was ever written. He signed in, hit "your account is not linked yet", and nothing anywhere surfaced that a real person was sitting in limbo — --list only knew about pending access *requests*, and he had never filed one. The only way it came to light was him saying so.

--list now also reports accounts with no live membership, in both branches. An account with nobody attached to it is a stronger signal that someone is waiting than a request is, precisely because that person is already through the door and looking at an empty screen.

Both existing accounts are linked to Westside, so the new section correctly prints nothing today; verified against the same set difference in SQL rather than inferred from silence.

Two design decisions, written down so they stay decided

06:22 pm AWST · d33a2eb

No Google sign-in, and the auth screens stay black. Both were raised, both were declined, and both are the kind of question someone re-opens in good faith three weeks later after reading the same BRAND.md and reaching the same reasonable-sounding conclusion. Recording the answer is cheaper than re-litigating it.

The dealer door gets a front, and a way to find it

06:13 pm AWST · 8bb94cb

The auth screens were a single narrow panel floating on an empty page, which says nothing about what is behind the sign-in and gives a dealer no reason to believe the thing is worth an account. They are now a split: brand and numbers on black, form on the lighter panel — the same inversion the dealer and market pages already use for hero against content. Two identical panels made the split read as an accident.

What goes in the left half is the part worth deciding, and for Spotlot it is not a claim. BRAND.md is a numbers-first brief and the footer promise is that every figure is derived from recorded observations, so the panel shows the corpus as it stands: 214,560 live listings, 155,668 cars, 990 dealers, $243.3M confirmed sold in seven days. It is the only honest way to say "this is worth signing into" to someone who has not signed in. Both figures come from cache keys the site chrome already computes, so it costs a cache read rather than a scan, and a failure drops the numbers rather than the page.

Each screen carries its own headline, because they are different questions: request-access explains why a human reads every request, forgot-password explains that nobody here has ever known your password.

Hidden below lg. On a phone a buyer wants the fields, not the pitch, and stacking them buries the form below the fold.

The link lives in the footer, not the nav. A sign-in link in the top nav of a public tool implies the tool needs one, and this one never will.

The auth trigger was an endpoint, and the invite needs a way out

06:05 pm AWST · 3049f46

Two things the Supabase advisors and a test run turned up.

handle_new_auth_user() is SECURITY DEFINER — it has to be, the auth system firing the trigger has no rights on public — and every other function added in 0177-0185 ends with an explicit revoke while this one did not. PostgREST exposes anything executable in public, so anon and authenticated could both reach /rest/v1/rpc/handle_new_auth_user. Postgres refuses to run a trigger function outside a trigger, so the practical risk was low, but "the type system happens to stop them" is not a control.

approve-dealer.ts gains --print-link, which mints the invite without sending mail. Supabase's built-in sender is rate limited to a handful an hour and is not meant for production, so for a pilot of one an invite that silently never arrives is a real failure mode — and a link in hand can go to the buyer however you already talk to them. It is still one-shot, still expiring, and still lands on /set-password where they choose their own. Nobody here sets or learns a password.

siteUrl() now says when NEXT_PUBLIC_SITE_URL is unset. The link points wherever that resolves, and a one-shot link aimed at production before the code is deployed there is a link the recipient burns on a 404. Found by doing exactly that.

Westside gets a login, and the appraiser turns out to be a person

05:55 pm AWST · 11327cf

Kiel Barker asked how many Hiluxes he sold this month. The honest answer needed a login to deliver and a lot of care not to fabricate, and building it turned up a privacy defect that had nothing to do with either.

THE DEFECT, FIRST. isPersonField was /^(buyer|customer|purchaser|owner_name)/i and `appraiser` does not match it. That key carries a whole staff record — {"name": "Devinder Thind", "appraiser_id": "thind", ...} — and it was stored on 915 of 4,000 sampled live listings across 204 SOURCES. Migration 0053 said "nobody's name belongs in a corpus about cars" and had been quietly wrong since. The guard now has an anchored rule and a contains rule (the second because `sellerSalescontact` hides the role mid-key), CI holds both, and scripts/scrub-person-fields.ts removes what was stored. 145,669 observations cleaned so far; it is resumable and unfinished on purpose, see below.

WHY THE SOLD FEED CANNOT SAY "SOLD". Westside's stock page filters on eight conditions and a car drops off when any one flips. A sale is one. A re-shoot taking photos_count below 5 is another, as are a deposit, a repricing and a move between yards. So the portal reports how long a car has been GONE, against measured relist latency: p50 0.9d, p95 4.0d over 1,672 events. Those boundaries live in exit_tier_spec rather than in code because they are CENSORED — the longest return we have seen is 7.1 days against a 7.9-day corpus, so the ceiling is our vantage point, not dealer behaviour. The page says so beside the numbers. Today that means 89 exits, 9 of them Hiluxes, and zero presumed sold until 23 August. The page shows the queue and the date rather than an empty screen.

Two things I got wrong and caught by exercising them:

- The tier recalibration pulled away_days into JS and sorted there. PostgREST caps at 1,000 rows whatever .limit() says, so it took the smallest 1,000 of 1,672, reported max 1.2d against a true 7.1, and declared the distribution "not censored" — confidently, plausibly, and backwards. The aggregate now runs in Postgres where it cannot truncate. - The price input had min=1 step=50, which makes the valid values 1, 51, 101 … so $43,500 was rejected and the form silently refused to submit. Every round number a buyer would type was invalid.

ACCESS. Invite-only, no signup page. The dealer analytics were already public on /dealer/[slug], so a login is not protecting those — it protects Kiel's own price entries, which are his margins. Requests go to a review queue; approval links a source_id by hand and sends an invite where he chooses his own password. Nobody here sets it. Memberships are revocable with an audit trail, and `basis` records manual vs domain_match so switching on domain-verified signup later need not re-decide old grants.

autovacuum it provoked saturated the instance and /value began answering 500. That is the one thing SPEC.md section 0 forbids, so the job now throttles by default and keeps its cursor in job_cursor. Resume it in a quiet hour with scripts/scrub-person-fields.ts.

The support page said "no ads, no accounts, no paywall". It now says no sign-in is needed to use any of it, which is the promise it was making and is still true.

The review found a comparison pointing the wrong way, and cars counted as listings

09:15 am AWST · 9dd260c

Twenty-six findings survived adversarial verification. The worst was mine and it was on the page's new headline chart.

The aged-stock median describes cars standing on the lot; it was printed "against 38 days across the whole observed market", and that 38 is a median over cars that already sold. Standing stock accumulates slow movers and completed sales do not, so the honest like-for-like figure is 47, not 38. Reproducing the render gate exactly: 491 dealer pages draw the chart, and on 103 of them the yard was shown as slower than the market while actually at or below it - 37,062 cars' worth of a named dealer being described the wrong way round. No board holds a comparable figure, so the comparison is gone rather than patched, and the caption says why it is absent.

Then the unit rule. A car can hold two live listings at one yard - 985 rows across 814 dealers - and two exits, 40 of 14,860. "The 60 longest-advertised cars" was sometimes sixty rows and fewer cars, and every count on the page was a count of listings. Both readers deduplicate by vehicle now, in SQL where the ordering already lives. Cardiff Volkswagen's exits went 90 to 89, TWG's live stock 1,167 to 1,164.

The claim I had just corrected was itself slightly overstated: still_live is source-agnostic on purpose, and 22 of 2,460 are the same dealer relisting the same car, so "still listed elsewhere" asserted a location the predicate never tested. It reads "still for sale" now, which is what the boolean holds. The same pass caught the SaleStrip aria-label still telling screen readers these were cars the dealer sold, after the visible copy had been corrected.

Two more claims were true only most of the time. "All quoted drive-away" printed whenever the share rounded to 95 or above, so a yard with one car on the other convention was described as having none; it is keyed on the exact remainder now, floors rather than rounds, and never names a category holding zero cars. And "longest advertised first" was asserted over rows that may carry no date at all, which on a yard publishing none is no ordering.

The caching was worse than doing nothing. revalidate = 60 has no effect on a dynamic segment with no generateStaticParams: the route stayed on-demand and every visit re-rendered. Verified by the route symbol, which was flat-f and is now the ISR marker. And live_snapshot carried exactly one index, on its primary key, so each dealer page scanned all 209,238 rows twice. Indexed on (source_id, advertised_days) it serves both readers, and the ordering, without a sort.

larger part of any gap you read against another yard", which is false in most comparisons; the exits panel said its market comparison "counts the same thing the same way" when the market side is used and demo cars only; a dealer with no live cars was told its site publishes no listing dates; and the share preview offered "1 cars observed live".

Prod verified across six yards including a one-car lot and one publishing no

The dealer page stops describing a lot nobody can look inside

09:02 am AWST · 2752e5a

Nine panels of prose and nothing to click, and two of the figures were wrong.

The first bug was a claim. A car leaving a dealer's shopfront was called "dealer confirmed" sold, but 2,468 of 14,880 exits corpus-wide (16.6%) are cars whose vehicle is still live under another seller we watch: a group site that kept the ad, or the yard next door. At TWG Cars that is 109 of 200 so-called sales, and at Cardiff Volkswagen 57 of 90. It is the one claim on this page a dealer would have been right to complain about. Those rows now read "still listed elsewhere", the eyebrow prints all three counts so they add to the total, and the panel is renamed "how fast their stock leaves" because leaving a shopfront is what it measures. The market-wide comparison counts the same thing the same way, so the gap still means something. The home page's "confirmed sold" was checked and is already correct: market_pulse requires none live before it counts a car.

The second was cost. dealer_profile() made a pass over live_snapshot per figure; it now makes one, which took a typical yard from 5,575ms to 165ms and the largest to 555ms. That paid for what the page was missing.

What is new is what is on the lot rather than what left it, which is the question both audiences actually have. An aged-stock histogram in 30-day bands, over the seller's own published listing date and never our observation window, with the denominator printed rather than assumed - 1,160 of 1,167 at TWG, 0 of 1 at Burnie LDV, which says so instead of drawing a chart. It is a survival curve, so the caption says the old bands are thinned by cars that already sold rather than by low intake. Then the cars themselves, longest advertised first and each one clickable: a buyer sees where the room is, a dealer sees their own aged tail at the top instead of inside a median. Then how the stock is presented, and a footer saying how every figure was obtained.

Two things the page had been quiet about. A median ask is not one unit across dealers - drive-away and excluding-charges quotes differ by about $1,470 on cars publishing both - so the header now says which one this dealer quotes. And 1,014 dealers shared one browser tab title and one share preview; they now carry their own name and suburb, which also revealed that the profile was being fetched twice per view, now deduped.

Found while building rather than after: the median line was drawn on a continuous 0-to-180 scale over categorical bars, so a 175-day median landed inside the 180+ bar; it is placed on the bars' own scale now. force-dynamic became revalidate=60, since an in-process cache gets less effective exactly as more instances spin up, and this is the highest-cardinality route on the site. Verified on four dealers including a one-car yard and one publishing no scrollers at 375 and 1280.

A source can state its own listing URL shape

08:53 am AWST · 38d14d6

The generic sitemap rule requires a nested path, because that is what keeps /accessories/mazda2/ and /models/cx-5/ from being fetched as cars. cars24.com.au publishes flat ones — /buy-used-toyota-rav4-car-22048674380544/, a single segment — mixed into a sitemap of 2,582 URLs where 1,959 are category and location facets. The crawl found 0.

listingUrlPattern is opt-in per source and consulted before the generic rule, so it changes nothing for the other 1,029 sources. Loosening the nesting requirement globally would have bought cars24 at the price of letting a single-segment /2024/ year archive through on any source that has one, and fixing one thing by breaking another is not a trade to make blind.

Dutton One reads its own key, and runs on the chain instead of by hand

08:51 am AWST · 6fc7d52

The AppSync key came from an environment variable that was never set on the crawl box and no longer exists anywhere — so Dutton One refreshed only when somebody ran the script by hand, which is the thing this pipeline exists to abolish.

resolveKey now reads it from the site's own JavaScript at run time. Nothing is being got around: this is the anonymous key Amplify ships to every visitor in a public chunk, and the browser reads it the same way one hop earlier. Fetching it rather than storing a copy is more honest, not less — we hold no credential, we cannot outlive their rotation, and it never enters this repo or the box's environment. Every request is politeFetch: their robots, their crawl delay, our real user agent. The env var still wins when set, for hand-runs. Matching the da2- shape rather than a literal means a rotation costs nothing.

config, so the scan stops at the first hit rather than fetching all 31.

Scheduled as a step in the deep chain rather than a timer of its own. It wants the same twice-daily cadence, and a separate unit would be a second thing to watch from outside the heartbeat health.ts now alarms on. Failure to resolve a key exits non-zero and is therefore loud, per DATA-QUALITY 38 — a silent skip is precisely how this would stop updating while everything reported success.

vehicles mapped, real stock across Caringbah, Richmond, Brisbane, Melbourne.

The deep pass waits where it can actually see

08:40 am AWST · 4c704f4

run-chain.sh already waited for a held slot, for the right reason: losing a deep pass costs twelve hours and every delisting in them, and delistings are the only evidence a car has sold. But it waits on pgrep, which sees one box. A crawl anywhere else — a laptop, a hand-run sweep — is invisible to pgrep and visible only to the run table, so the deep pass walked past the wait it was entitled to and into the guard that returned success.

Measured over twelve days before fixing: two of the box's deep windows fetched ~2,000 pages and recorded zero delistings, against 18,000-22,000 pages and 250-700 delistings for the ones that ran. About one deep pass in six.

So the wait moves into crawl.ts, the one choke point all three scheduler paths share. Ninety minutes, well under the twelve-hour period so a genuinely stuck crawl yields the slot rather than stacking two deep passes. Abandoned rows are re-reaped each round, because the reap above runs once and a crawler that dies during the wait would otherwise hold the slot until its rows aged out. The shell keeps its pgrep wait; it is simply no longer the only one.

Verified end to end against a held slot: the deep pass waited, picked the slot up when it was released, and crawled — pages=4 extracted=25 parsed=100%. A fast pass still exits clean and immediately, since it really does catch the next slot two hours later.

A skipped deep pass now says so, and the box has a pulse

08:34 am AWST · e6ee3ba

The crawl guard that refuses to start beside another crawl returned exit 0, fourteen lines below a comment about a pass that "exited having done absolutely nothing — while the workflow went green".

It happened again on 2026-08-17: deep started 15:43:30, ran the whole chain, filed a green 21-check integrity run at 15:45:27, stamped its heartbeat ok. 118 seconds and ~600 pages against the 18,000-22,000 a real deep pass fetches — and no delistings for another twelve hours.

run-chain.sh already knows a deep pass must not skip; it waits up to two hours for one. But that wait is pgrep, which sees only that box, so a crawl running on a laptop walks the deep pass past its wait and into the database guard, which returned success. The asymmetry now reaches the exit code: fast still exits clean because it genuinely catches the next slot, deep exits non-zero so failed=1 and the heartbeat records ok=false.

health.ts gains the matching read. Every other check reads the corpus, and a corpus looks healthy for days after crawling stops — the listings sit there being live and pg_cron keeps the boards fresh. Only a missing heartbeat says the machine has gone, and nothing read it. Each kind states its own period, so one rule covers the two-hourly fast pass and the twelve-hourly deep one: two and a half periods, because one missed run is a reboot and two are not.

Verified against a synthetic in-flight row: deep exits 1, fast exits 0, and health.ts stays green on the live corpus.

Valuation 0.13.0: Lexus folds, Mercedes trim noise, thin-year confidence

08:21 am AWST · 9e33ed4

Three changes shipped today, all measured, none of which move an estimate except where it was wrong:

* Lexus variants fold into badges. RX was 160 cars beside RX350's 61 and RX450h's 38 as if they were different nameplates; it is now 361 and the picker offers 14 models instead of 60-odd fragments. Verified against all 76 Lexus model strings: 61 folded, 15 base models untouched, 0 wrong.

* AMG, 4MATIC, Auto stop splitting a Mercedes trim. CLA45 was six trims of two to twenty-one cars; it is one of 40. CLA45 S stays separate, because 310kW against 285 and $92,495 against $38,800 is a different car.

* Confidence is discounted when two or three comparables share the subject's year — measured at 15.2% of such answers landing past 15% error, against 0.0% on dense cohorts.

* Trim divergence gains a same-year trigger as an OR, after the eval showed replacing the all-years test would strip protection from 2,857 cases to gain 441.

Detect trim divergence within the subject's year too, as a second trigger

08:01 am AWST · 827bb3a

trimIsDivergent compares a trim's median against the rest of its model across every model year, and an age mix can hide a real gap. CLA45s here are mostly 2014-2017 cars at $23,950-$50,990, so the trim's all-years median sits near the newer CLA200/CLA250 median, 1.4x never appears, the trim clause gives way under the survivor guard, and 2019 CLA200s price an AMG — the C63 failure happening to a different car.

Measured before changing it, and the measurement rejected the obvious fix. Over 10,473 (model, year, trim) cases with enough of both sides to judge, *replacing* the all-years comparison with a same-year one finds 441 divergent trims it currently misses and stops calling 2,857 divergent that it currently protects — six and a half times more protection removed than gained. Volkswagen Tiguan 132 TSI Comfortline is the shape of that loss: 1.55x across all years, 1.12x inside any single one.

So it goes in as an OR, which can only add. Every trim the current rule protects keeps its restriction — C63 AMG S unchanged at $104,361, Tiguan Comfortline unchanged, the dense controls identical — and 441 gain one.

It does not fix the case that prompted it, and that is worth stating plainly. There is exactly one 2019 CLA45 in the corpus; the other 2019 car is a CLA45 S, a different trim. The same-year test needs two of the trim before a median means anything, so this case is short by one car. Fixing it with a rule would mean reading divergence off a single asking price. It needs data, not cleverness.

Sanity unchanged: largest gap -6.4%, 5 of 5 year-level cases exercised.

Page the reads that had already crossed the 1,000-row ceiling

07:56 am AWST · 0bec023

Swept every unpaged .select() after the coverage page broke on it. Three were already truncating and two were days away.

mine-dealer-names source (1,058) and discovery_candidate (1,543) mine-linked-dealers source (1,058) fit-detail-caps 989 enabled dealer sites probe-sitemaps 989 enabled dealer sites

The first three are the ones that mattered, and they fail in the direction that wastes work rather than losing it: each builds the set of things already known — sources we crawl, candidates we have probed — so a truncated read makes the miner re-propose hosts it already has and the prober re-probe hosts already answered. Silent, and it looks like the channel drying up rather than the query lying.

fit-detail-caps is the one to have caught early: it decides every source's detail budget, and a source it cannot see keeps whatever cap it was seeded with. At 989 of a 1,000 ceiling that was a few days from quietly capping new yards at 200 pages forever.

This is the third instance of this class today after the coverage page and the covered-dealer check inside mine-dealer-names itself. The ceiling belongs to the server, so no limit() is large enough; keyset paging or an RPC is the only fix.

Coverage page: page the source list past PostgREST's 1,000-row ceiling

07:38 am AWST · 652180f

The context-dataset and marketplace sections of /coverage rendered empty, and the configured-source count looked stuck near a suspiciously round number. Both are one bug. The page fetched every source with an unpaged select ordered by kind; `dealer_site` sorts before `gov_dataset` and `marketplace`, so once the dealer sites passed a thousand — 1,030 today — the first and only page was entirely dealers and the two sections below described nothing.

Nothing errored. The page simply stopped mentioning two thirds of what the crawler does, which is the worse failure: an empty section reads as "we survey no government datasets" rather than "this query was truncated".

The same ceiling already bit the runs query on the same page, and the comment recording it names Westside Auto Wholesale as the casualty — the dealer Taj searched for and could not find. One ceiling, two tables, and both of his symptoms.

Paged by id with the display sort applied after every row is in hand, since keyset paging has to order by the key.

Discount confidence when few comparables share the subject's year

07:32 am AWST · 3099138

Measured before shipping, and the first measurement was of the wrong thing. A pass over 400 cohorts valued *without* a badge found thin years were as accurate as dense ones — 2.1% median error on two or three same-year cars — and would have killed this change. Users pick a badge, and re-running on that path over 498 cohorts inverts the result:

same-year cars median |gap| share past 15% 2-3 5.3% 15.2% 4-7 2.0% — 8-15 2.9% — 16-40 2.0% 0.0% over 40 1.5% 0.0%

High confidence on two or three same-year cars is wrong past 15% one time in seven; on dense cohorts, never. The cliff is under four, so that is where the discount starts, and it is gentle — three cars of the right year is thin, not worthless.

Deliberately a confidence discount and not a change to the comparable set. Taj asked what a trim-restriction fix might break, and the answer is in this file already: only three cars are both 2019 and a C63 AMG S, and dropping the trim clause let twenty 2019 C200s and C300s answer for an AMG. Trim and year pull opposite ways and the fallback chain already arbitrates them; moving that line fixes one case by breaking the other. Changing only the confidence leaves every comparable and every estimate exactly as it was.

Sanity holds: twelve anchors within tolerance, largest gap -6.4%, and the thin-year cases correctly move down — Polestar 4 medium to low, BMW iX3 low to insufficient.

Fold Lexus variants into badges; stop AMG and 4MATIC splitting a trim

07:03 am AWST · 22b230d

Two taxonomy faults Taj found in the valuation picker.

Lexus names a variant by gluing the engine onto the model, and the corpus stored those as models: RX had 160 cars while RX350 (61), RX450h (38), RX350H (24), RX300, RX330, RX500H, RX350L, RX270, Rx450H+, RX450HL and RX200T sat beside it as separate nameplates. NX split seven ways. Each fragment was its own comparable set, so a 61-car RX350 cohort never saw the 160 in RX, and RX350 against RX350H was a capitalisation rather than a difference.

Same rule shape as the existing Mercedes one, with a base-model list drawn from nameplates that already exist in their own right. Verified against all 76 Lexus model strings in the corpus: 61 folded, 15 base models left alone, 0 wrong, other makes untouched. RX is now 361 cars and NX 346, and the picker offers 14 nameplates instead of 60-odd fragments.

foldableMakes() needed Lexus adding too. The warning above that function is not hypothetical — a rule that exists in foldModelIntoBadge but not in that list runs on nothing and reports a cheerful zero, which is what the first sweep did.

The Mercedes fault is the other half: a CLA45 arrives as CLA45 AMG, CLA45 Amg, CLA45 4Matic, CLA45 AMG Auto 4MATIC and worse, so one car became six trims and the badge dropdown offered "CLA 45 AMG", "cla 45 amg", "4Matic" and "AMG S" as alternatives. AMG on a 45 is the sub-brand the number already implies, 4MATIC is Mercedes' word for all-wheel drive when awd/4x4/4wd are noise already, and Auto is a transmission. S is deliberately kept: a CLA45 S is 310kW against 285 and a $92,495 median against $38,800. 15 badges now make 8 trims, CLA45 gathering 40 cars and CLA45 S staying separate with 15.

KGM is SsangYong: do not guess a group both names

06:55 am AWST · f92e6eb

The sibling sweep seeded frankstonkgm and frankstonssangyong, and the same pair for Rockingham, Dubbo and Geelong. KGM is SsangYong renamed in 2023, so each pair is one showroom answering on two domains: 220 listings went live under two source ids and the integrity check caught it after the crawl.

The redirect guard could not have seen this. Neither domain redirects to the other; the dealer simply answers on both. So the rename belongs where the guessing happens, not downstream where two sites serving identical stock look like an ordinary duplicate.

Fixed in three places. The four SsangYong sources are disabled and pointed at their KGM twin, their 220 listings merged into the survivors, and the generator now skips a marque the group already holds under any of its names. Holden/GMSV is listed on the same basis — Holden closed in 2020 and its dealers largely became GMSV, so a group with both is usually one showroom under two signs.

66 sibling sources crawled: 3,785 new listings. Integrity back to green.