Method · Changelog · 3 Sept

Every change, in the words it was made with

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

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

Thursday 3 September 2026 · 73 changes

A count of price changes counts the ones a dealer made

10:14 pm AWST · 6f24ff4

0261 marked 187 price_change rows whose two readings were of different cars. The VIN proves it: a dealer's URL was reused for another car, and the "change" is the difference between two cars' asking prices. Nobody raised a price. Marking them changed no counter, because every counter asked only for event_type = 'price_change'.

Corpus-wide the 187 are noise -- 0.137% of 136,559, and the cuts-to-rises ratio moves 6.43 to 6.46. Per yard they are not noise. dealer:villagemotors recorded 97 price rises and 91 of them are fake: six of its URLs took a second car and then alternated between the two, diffing on every swing. Its analytics now read 6 rises and 180 cuts, against 97 and 269. A statement would have told that dealer it raised prices ninety-seven times.

So `and voided_reason is null` now sits beside every predicate that COUNTS our own events: ten functions, seventeen predicates, patched textually with an assertion that refuses to apply if a pattern misses. Five functions are left to see voided rows with the reason in each body -- among them the cross_view_price_changes detector itself, which must see them to find more. A separate five are untouched because they read the seller's own price_changes_count from the listing, which is the dealer's tally, not our observation.

Historical rollups are NOT recomputed. The worst affected day is 2.0% of its rises (6 fake of 319 on 2026-08-13) and most are under 1%, which is not worth rewriting a published series over. Today's re-finalise moved 2026-09-02 to 1,291 rises. Both issued statements are for yards with no car swap at all, so nothing already sent carries a fake change.

The check that asserts this earned its keep on its first run, naming run_slow_integrity_checks -- a function my by-name reading had missed. It is check 31 in the suite, so the eleventh cannot arrive unnoticed.

The invariant suite runs from a checkout that is current by construction

09:56 pm AWST · a5fa58b

check-integrity.ts is the corpus's conscience — 30 invariants, several of which have caught real damage — and it ran in exactly one place: the deep chain on the VPS. So it ran whatever the VPS had pulled. Today the box was filing runs of 23 checks against 30 in main, and had not filed one for thirteen hours. The suite in main was running nowhere.

WHY THIS DOES NOT BREAK THE UMBRELLA, which was the thing to get right. health.ts treats integrity_run going quiet for 24h as the alarm that catches the box dying. If this job's rows counted, they would keep that table warm over a dead box and disable the one alarm that notices.

They do not count, and the seam was already there: check-integrity.ts sets `scheduled` from SPOTLOT_CHAIN, which only the VPS chain sets. This job deliberately does not set it. Its rows file as ad-hoc — never moving health's baseline, never satisfying its heartbeat, never speaking for production — while still checking the corpus every six hours and still setting the widest check_count, which is what makes the box's drift legible to the version-skew alarm repaired earlier today.

Verified read-only: no insert, update or delete anywhere in the suite except its own integrity_run row. 94 seconds to run.

check-budgets.ts is deliberately NOT here. It measures response times against thresholds taken from Perth, and a GitHub runner sits a continent away from an ap-southeast-2 deployment, so it would fail for its own latency rather than the site's. A check that fails for a reason unrelated to what it names is worse than no check.

Auditing the streamed HTML counts React's transport, not the page

09:52 pm AWST · eb9bb09

A DOM audit of /moving reported 350 tables, 347 unnamed. The page has three, all named, every header scoped. React streams suspended content as <table hidden><tr id="S:2"> blocks with a $RS() call that moves them into place, so curl-and-parse sees every placeholder and the hydrated document sees none — they are hidden, ignored by assistive technology, and gone after hydration.

Recorded because I nearly reported 347 phantom findings, which is the same error as any other: measuring one population and describing another. The wire is the right thing to measure for bytes and for head metadata. It is the wrong thing to measure for structure or accessibility.

Verified on the hydrated page: 3 tables, 3 resolvable names, 13 headers, 0 unscoped, 0 duplicate ids.

A shared component's hardcoded id renders once per instance

09:49 pm AWST · f6f2741

The repricing panel's Table component renders twice — by state and by basis — and carried a hardcoded id, so /market shipped a duplicate id and the basis table announced itself as "By state · where the car is advertised". Its accessible name now comes from the caption prop: the same words, no id to collide.

The generated ids were also slugged from the basename, so seven different page.tsx files all produced tbl-page-0. No live collision — they are separate documents — but a trap for the first component that moves. Slugged from the full path now, and verified: 17 declared, 17 referenced, none dangling in either direction.

Every table says what it is, and every header says which column it heads

09:46 pm AWST · b876481

A screen reader meeting one of this site's 27 tables announced "table, 7 columns, 13 rows" and nothing else. Each one sits under a visible heading and none of them was associated with it.

18 are now wired to that heading with aria-labelledby, so the accessible name IS the visible name and cannot drift from it — the id is structural, the words come from the page. The 9 with no heading within reach carry an aria-label written to say what the table holds.

And 40 header cells gained scope="col". Without it a screen reader has to guess whether a header belongs to its column or its row, and this site's tables are wide enough that guessing wrong makes them unreadable.

The rest of the audit came back clean, which is worth recording: no interactive element on any page lacks an accessible name, every SVG is either labelled or aria-hidden, and every img has alt text. The two state selects that looked unlabelled are label-wrapped — a false positive in my own check before I read the source.

The palette's legibility is a test now, not a coincidence

09:42 pm AWST · 4f95fef

BRAND.md commits to a dark ground, one accent, and yellow that only ever carries black. Those are legibility claims as much as aesthetic ones, and nothing tested them. Measured, all twelve pairs the site actually paints meet WCAG AA — so the palette was accessible, which is not the same as being kept accessible.

The pair to watch is the yellow panel's own quiet text, rgba(0,0,0,.55) over #fcfe3b: 4.70 against a 4.5 threshold. Twenty hundredths of headroom, and a token nudge would spend it silently, in front of a developer for whom it still read fine.

Tokens are parsed from globals.css rather than copied into the check, because a check carrying its own copy of the values it checks tests nothing but its own copy. Alpha is composited rather than approximated. And it was verified by planting a quieter quiet (#9a9a9a to #7a7a7a) and watching two pairs fail — the same discipline as the figures lint, whose first three versions all passed a planted bare figure.

The two other checks nothing was calling

09:37 pm AWST · c8f9a3f

check-brand-copy measures the social copy against each platform's character limits — the thing you otherwise discover after pasting. check-brand-layout measures the banner's geometry and yellow coverage against BRAND.md. Both pure, neither enforced anywhere.

That is now four checks moved from "runs when someone remembers" to "runs on every push". The two that cannot go here — the integrity suite needs the database, the budget check needs the live site — are the subject of the scheduled-job work.

CI runs the figures lint, which until now ran when someone typed it

09:35 pm AWST · e2903da

A .figure element with no prose anywhere near it is a statistic printed with nothing saying what it counts, over what, or when — the class about 180 of the audit's 493 findings belong to. The check that catches it existed and nothing called it, which is the shape of defects 7, 14, 32 and the portal priority flag.

Pure static analysis over the JSX: no network, no database, no secrets, so it belongs on every push rather than in the scheduled job.

A merged dealer id points its canonical at the survivor

09:01 pm AWST · 8567a19

The redirect was tried and reverted, and the reverting is the finding: the route is ISR with generateStaticParams, and a permanentRedirect thrown inside a prerendered render never reached the response — verified over three cache generations and a full stale window, with the underlying data confirmed correct in isolation. Reporting it as shipped on the strength of the code reading correctly would have been wrong.

The canonical carries the same signal reliably: a crawler is told which of the two pages is the page. And the explanation panel comes back, which is better than the redirect was for the person who followed an old link — they get told where the cars went rather than being moved without being told.

Record the SEO work in the status table

08:55 pm AWST · 9bfea78

The dealer hub stops rendering fresh for every visitor, and merged ids redirect

08:51 pm AWST · 8fed55a

/coverage was force-dynamic, so it answered no-store and every arrival paid a 631ms render of 1,046 audited sources. Nothing on it moves faster than the fifteen-minute board it summarises. Cached for five minutes — and it now says so, because the one thing caching costs here is the "crawling now" flag, and a page that has started caching has to state it. Five minutes is short enough that a source shown as crawling was crawling within the last five, which is what a reader of that page wants to know.

And the 33 merged source ids redirect to their survivor instead of rendering 200 with a sentence explaining where the cars went. That page was a duplicate of the survivor's for a crawler and an extra click for a person, and the sentence answered a question the redirect answers better. Permanent, because the merge is: 0082 will not let a merged id be re-enabled.

Panel headings are headings, and an answering engine has something to read

08:48 pm AWST · df43f7a

108 section headings were paragraphs carrying a class. The document outline was therefore an h1 and nothing else on every page, which is worse for a screen reader than for a crawler and bad for both. Only the eyebrows that are the first child of a <section> were promoted: the other 30 are labels inside cards and rows, and turning a label into a heading makes an outline worse rather than better.

Three of them spanned more than a dozen lines and closed the wrong tag, which the typechecker caught immediately — the reason to run a mechanical edit past a compiler rather than to trust it.

And JSON-LD: Organization and Dataset, which is what an answering engine reads when deciding whether to cite you. Every claim in it is one the site makes on screen, and there are no figures in it at all — a JSON-LD carrying a live count would be a stale count wearing a schema, which is the same argument the OG image's own header makes and the reason the model titles carry no numbers either.

A year this model does not hold is a bad URL, not an empty cohort

08:45 pm AWST · 9c2984f

/model/ford/ranger/1994 answered 200 with the whole model's page under a 1994 heading, because Ticker falls back to every year rather than drawing an empty chart. Reasonable for a person who mistyped; catastrophic as a URL space. Every model crossed with every plausible year is about 180,860 addresses that all resolve, all near-duplicate, and all pointing a crawler at a page about something else. 404 is the honest answer to "show me the 1994 Rangers" when there are none.

The test is `points`, not the board's `years` array: `years` carries only cohorts of three or more, so a year holding one car would 404 on a page that can honestly render it.

Held years now carry their own title, description and canonical rather than the homepage's.

Every page says where it is, and there is now a way to find them

08:42 pm AWST · 9590675

The root metadata pinned og:url to the homepage, and every route inherited front door on every share, with every canonical signal pointing there. The url is gone from the root — a route that forgets now emits no url rather than a wrong one — and each route states its own.

Four routes had no metadata at all, so /market, /moving, /coverage and /value carried the homepage's title. The model pages did too, which is the whole programmatic case for the site sitting under someone else's headline.

The figures in that metadata are chosen for what does not rot. A live count in a title is a stale count the moment a crawler caches it — the OG image's own header makes this argument and is right — so the model title carries the make and model, and any count in a description carries the date it was true. 1,044 dealer descriptions asserted a count as current off a board rebuilt twice a day.

And there was no robots.txt and no sitemap, so all of the above was invisible. The sitemap carries 752 models with six or more live cars, the dealers holding stock with the merged slugs excluded, the four issued reports and the fixed pages; lastModified is the board's own stamp, because a sitemap date is a claim about when a page changed and inventing one is the same lie as inventing a figure. robots.txt keeps the 312,335 listing pages on the site and out of the index: they are genuine records and they are also the thinnest documents here, and spending a crawler's budget on them dilutes the pages that aggregate them.

Every section of the program has now moved

08:38 pm AWST · 9514dad

An alarm that has never fired is an alarm nobody has tested

08:38 pm AWST · b6f95fd

health.ts carries a version-skew alarm — "the production integrity run has N checks where M exist, the box is behind main" — written because a scheduled run from an older commit can pass a suite that no longer exists, which is exactly how a masked failure went unnoticed once before.

It read the widest run with `order by check_count desc limit 1`. Postgres sorts NULLS FIRST on a descending order, 30 of 103 recorded runs predate the column, so it read null, `most` became 0, and the comparison could never be true. The alarm has been unable to fire since the day it was written.

Excluding the nulls, it fires immediately: the crawl box is running 23 checks against 30 in main.

And the failing-check line is dated. It said "1 check(s) failing on spotlot-crawler" about a run thirteen hours old, with nothing saying so — the undated claim this whole programme has been removing from the surfaces, sitting in the thing that watches them.

A median of two samples is their mean

08:36 pm AWST · 1055fea

Run with --samples 2, the budget check reported /moving at 611ms against a 500ms budget; five consecutive requests measured 210 to 365ms. One slow sample dragged a two-value "median", and the header said median of N. Floored at three, so the word means what it says.

"UNKNOWN" sitting between NSW and WA reads as a place

08:31 pm AWST · 8f187e4

It is cars whose listing does not state one. Named for what it is, like the basis bucket beside it.

Who is marking stock down: the matched panel that replaces the refused index

08:29 pm AWST · 078781b

The research refused a price index outright and the refusal is in the numbers. Over the days we have recorded, the live corpus went from 49,673 listings to 227,302, so any price level plotted over that window is a chart of which dealers we onboarded. 40% of asks declare no basis and the listing-to-car ratio drifted 1.20 to 1.48, so even a car-deduped median moves with mix rather than with price.

A matched panel answers the question an index is asked for without any of price moved. Composition is held fixed by construction, so nothing in it can be an artefact of onboarding. 79,724 cars priced on 26 August and again on 2

Rule 7 is the entire point. The refutations found one defect seven times — a numerator from what the crawler re-read over a denominator from what is live at build time — and here they are the same set. A car we did not re-read at both ends is not in the panel at all, rather than counted as unchanged, because "we did not look" is not "it did not move".

It immediately shows something an index could not: cars quoted excluding government charges are marked down at 21.7% against drive-away's 12.8%, on panels of 18,383 and 41,619. WA marks down at 21.4% against Queensland's 13.5%. Cuts under 30 cars are not shown.

truncates and rebuilds, so a build landing in that window failed the whole deploy with a lock timeout — a deploy broken by a board refresh rather than by any code in it. The page renders its refusal instead, which is its own thesis applied to itself: it states no figure it cannot read, so when it cannot read them it shows none.

Bring the status table up to what shipped

08:16 pm AWST · 5b19bc5

The review's findings, worked through

08:14 pm AWST · ea59d13

An adversarial pass over today's work found twelve defects, several of them mine from this morning. Ten fixed here; two are notes.

THREE TOTALS FOR ONE QUANTITY, ON ONE SCREEN. For the week of 24 August the report headline said 10,723 "cars confirmed sold", the day-by-day table said 10,236 and the by-state table said 11,129. The headline had no basis filter at all, so it counted exits the method page shipped this morning says are "never counted as sold anywhere on this site". The two tables are the same rule measured at two instants: the state column was written on the day, the market column was backfilled today against the live listings of today. The report now leads with proved exits, which is the one figure written in a single pass in both rollups — 7,700, and all three agree. The wider count sits beside it, labelled as what it is.

A DAY THAT HAS NOT HAPPENED IS NOT A GAP. The running-week preview told readers "1 day in this period recorded nothing" about today, before the 12:50 UTC roll-up had run. Days that are due and missing are gaps; days not yet due are pending, and say so.

THE METHOD PAGE TYPED IN A FIGURE, ON THE PAGE ABOUT NOT TYPING IN FIGURES. "$1,470 on cars that publish both" — no sample size, no date, no source, and repeated by hand in two places on /dealer. Measured live from cars carrying both quotes at once it is $3,056 over 54 cars, with the middle half between $1,712 and $4,697. It also dated a five-hour-old snapshot with now(), and divided 127,599 VINs including out-of-scope rows by 206,932 vehicles excluding them, printing 62% where the matched populations give 61%.

A CHECK THAT COULD NO LONGER FAIL. 0246 moved the no-live-listing predicate into recent_sales, which turned check 15 into an assertion of the function's own WHERE clause. It is the check that caught the failure on 2 September and it could not have caught its successor. It reads the facts table now, which is where the drift would be and what the pulse and the boards read.

THE PRIMITIVE NOTHING IMPORTED. components/Figure.tsx was 226 lines whose stated purpose is enforcement, with zero call sites; yard/page.tsx had reimplemented the same union locally. Both yard surfaces now take the shared one, and the statement — the one document a dealer keeps — went from eleven tiles with no denominators to eleven that state them. Its own `basis` prop was plain-optional while its documentation called it required, which is the hole the file exists to close, left open in the file that closes it.

dealer_live_position_scoped never deduped where dealer_profile always has — unfalsified at the one group configured today, and wrong the first time a group lists one car on two branch sites; on a two-source group it overstates by 162. The chart-proof aged read had no upper date bound while the comment added this morning said it did, so 40 September cars were in an August sheet. And /coverage's dateline reached one of the four pages 0253 named.

Two findings left as notes: check_dealer_exit_overcount is guarded by its primary key rather than by the check, and its comment now says so rather than claiming to catch a double count; and the Westside proof sheet says "exits" rather than "sold", which is already correct for a yard whose every exit is unproved.

The comparables table ships twelve rows, not eight hundred and thirty-five

07:54 pm AWST · 874c4a5

Ford Ranger's model page was 1.94 MB raw and 226 kB on the wire, against a 60 kB budget. Most of it was the comparables table rendering every car as a hidden <tr> so the brush could unhide the matching ones. Twelve rows are visible and the brush shows at most thirty more, so about 790 rows existed solely to be revealed — roughly 200 kB of HTML and as much again in the flight payload, on a page a phone downloads.

Every field a row needs is already on its scatter mark, because the mark is already on the page for the chart. So the marks carry year, condition and price alongside the id and odometer they already had, and the brush builds its rows instead of unhiding them. The head rows stay server-rendered and are hidden rather than removed, because they are the resting state and must come back on Escape without a rebuild.

A price history is a step, not a slope

07:48 pm AWST · e87438e

An advertised price holds at a value until the seller changes it. Drawing a straight line between two readings draws a gradual change that never happened, on an x-axis that is our own fetch schedule rather than anything the seller did — so the steeper a segment looked, the more it was telling you about how often we happened to look. CHARTS-PLAN rejects that shape by name.

The step places each change at the first moment we saw it, which is the earliest moment it can honestly be placed, and it makes the flat runs read as what they are. Most prices never move, so most of this chart is flat runs.

A clamp rail is not a measurement, and no chart has five rungs

07:46 pm AWST · 417a8b3

Two findings from the chart-kit brief, both verified against the corpus.

THE RAIL. robustKmSlope bounds the Theil–Sen median to [-0.35, 0] $/km so a thin or wildly priced cohort cannot produce an absurd adjustment. That is right. What was wrong is that the bounded value was then labelled as the measures -$53,786 per 10,000 km against a legend reading "trend, $3,500 per 10,000 km" — a fifteen-fold error presented as a finding. Seven models measure a positive slope, clamped to zero and drawn flat, which reads as "kilometres do not affect price here" and is a statement nobody made.

/value already refused the flat case, for exactly the right reason, and had no way to know about the floor. The estimator now returns both numbers, so the arithmetic keeps using the bounded value — that is what the band is for — while every surface that LABELS it says which it has. Where the band bit, no line is drawn and the page says what was measured and why it is not a trend. The valuation's own workings say the same rather than printing a limit as a fit.

THE DENOMINATOR. Every chart frame on the site printed "Evidence rung N of 5". No chart defines more than two rungs and market.ev_state_share defines one, so a cohort standing at the top of its ladder rendered as a fifth of the way up it — a denominator that makes the best evidence we have look like the worst. The frame now reads the chart's own ceiling from chart_rung.

Document the deploy, which is not `git push`

07:41 pm AWST · fff99de

Pushing to origin/main feeds the crawl box, which pulls and runs the migration-drift check. Vercel is deployed from a working copy. They are two separate acts and both are needed, which was tribal knowledge until now.

`npm run deploy` is the command, because the build stamp is written immediately before the upload and a bare `vercel --prod` ships a page that cannot say which build it is.

The build sha travels as a file, because three environment variables did not

07:39 pm AWST · decb658

Each attempt looked right and shipped nothing.

VERCEL_GIT_COMMIT_SHA is set only for builds Vercel makes from a connected repository; these are deployed with `vercel --prod` from a working copy, so it is empty. `git rev-parse HEAD` in next.config.ts works locally and not on Vercel, which builds from an upload with no .git directory — the stamp was in the local build output and absent from the deployed page, which is a confusing half-hour until you notice. `--build-env` did not reach the build either.

What is reliably true is that the deploy uploads the directory. So the sha is written into a generated file immediately before the upload, by the same npm script that deploys, and cannot be forgotten because it is not a separate step. The file is committed with an empty value: the stamp is a property of a deployment, not of a commit, and committing the real one would record the PREVIOUS sha in the next commit — a value wrong in exactly the way the stamp exists to prevent.

The stamper refuses a dirty tree, for the same reason. A stamp naming a commit that is not what was deployed is worse than no stamp: it is a specific wrong answer to the only question it exists to answer.

The build stamp was absent from the deploy that introduced it

07:36 pm AWST · 30539fc

VERCEL_GIT_COMMIT_SHA is set for builds Vercel makes from a connected repository. These are deployed with `vercel --prod` from a working copy, which carries no git context, so the footer rendered nothing — caught by looking for the stamp on the live page and not finding it.

next.config.ts now injects the platform's value when there is one and `git rev-parse HEAD` otherwise, baked in at build time because that is what it describes. Empty when neither works, and the footer prints nothing rather than seven characters of a lie.

A market report carries series, not four numbers and two lists

07:33 pm AWST · 1b61b99

market_report already held immutable snapshots of closed UTC periods and refused open ones — the hard part, done. What it held was seven keys: a total, a value, two top-five lists and three basis sentences. Nothing a reader can see a shape in and nothing a chart can be drawn from.

The series obeys the rules the surfaces do. Gaps stay gaps: `series` has one entry per RECORDED day and `gaps` names the days with no row, so a line must break rather than join across a missing day. Units are stated in the payload itself, and where cars and advertisements both exist both are given, because a column that picked one silently is the commonest mistake in this data. Breaks travel with the window, so a period spanning 29 August cannot be rendered without the lapse reclassification beside it.

And what is not there says so. The price-basis mix is recorded from today and cannot be backfilled — it describes the stock standing on a day, and live_snapshot only ever describes now — so a period before today gets an entry in the report's own withheld list naming the metric and the reason, rather than borrowing today's mix, which would be a figure from the wrong day wearing the right label.

The four reports already issued do not gain a series. `on conflict do nothing` is the archive policy and it stays: a document that changes after issue is not a record of anything. The running-week preview computes both halves live, so the preview and the report it becomes cannot disagree about shape any more than they can about what a week is — which is also how this was verified, there being no closed period left unissued.

Quarterly is now a kind the generator understands. It refuses like any other open period, so the first cannot issue until October: a quarterly report over a corpus 21 days old would be a monthly report with a grander name.

The method page's tab said Spotlot twice

07:27 pm AWST · 00f6d8c

The layout applies a "%s · Spotlot" template and the page's own metadata repeated it.

A method page that reads its own numbers, and a build stamp

07:25 pm AWST · 06e1657

Every persona in the audit asked independently for a page defining observation, listing versus car, sold, lapsed, merged, the rungs and the basis mix. The obvious way to write one is to type the counts into the prose. This program has spent a day removing figures typed into prose — /moving's 85%, a comment claiming a valuation costs a second, a movers label saying "cars" about repricings — and a methodology page carrying a stale number would be the least excusable of the lot.

So it states nothing it does not read. One RPC, one round trip, every count off a table rather than the observation log. The open-defects section is generated from the integrity suite's own last run, so it is the same list we look at rather than one written from memory.

It says the things the site has been careful about all day and never collected in one place: that a listing is not a car and the ratio is 1.48 right now; that nobody in Australia publishes what a used car sold for, so "sold" here means the seller's page said so and the price is the last one they asked; that gone-unproved is never counted as sold and at some yards it is every exit, so folding it in would flatter exactly those dealers; that two fifths of live prices do not say whether they include government charges, so a median across them is a median across two units plus an unknown.

And a build stamp in the footer, because nothing on the site said which build a reader was looking at. That matters more here than most places: this site publishes its own defects and their fixes, so "is the fix I read about actually live" is a fair question that could not be answered.

Record the valuation measurement, with what the audit got right and wrong

07:20 pm AWST · 6b38438

The audit named the shape correctly — listing_current resolving fields per row — and pointed at the comparables. The cost was in a count inside depthForSubject, which the page waits for in the same Promise.all. valueCar was 940ms warm throughout.

The four subjects the audit timed, re-measured: 1.7/7.3/10.2/17.5s becomes 0.93/0.99/1.16/1.73s. The finding was never the total, it was the slope, and the slope belonged to something else.

The /coverage budget is set to what the page costs, with the reason

07:13 pm AWST · 5f10b37

631ms to first byte, down from 1,058. Two covering indexes on crawl_run took latest_source_runs() from 957ms to 226ms; passing the page bound as an argument took its second page from 422ms to 5ms; narrowing select(*) to the nine columns rendered took the rest.

The 500ms in the discovery brief assumed the gap was one RPC. It was, and closing it did not reach 500. Getting there means truncating a table whose purpose is that it lists everything, or putting a Suspense boundary under the header so TTFB reports the shell — which would make the gauge read better without a reader waiting less, and this check exists because that is exactly what the old budget did.

So the budget says 800ms and says why. Streaming the table below the counters is still worth doing, because the counters become useful sooner; the answer column would still read 669ms and still be the number that matters.

/coverage fetches the nine columns it renders, not every column source has

07:10 pm AWST · e10de41

`select("*")` over 1,046 sources pulls each row's crawl_config jsonb and extractor settings to render an id, a name and a link. The nine columns SourceRow already declares are the ones the page uses.

One literal string, not a concatenation: the client infers the row type from the select, and a concatenation is not something it can read.

Page two of a set-returning function should not recompute page one

07:08 pm AWST · a323c66

/coverage pages latest_source_runs() by key because it returns one row per source and sources passed PostgREST's 1,000-row ceiling. That is right, and it cost double: page two asked for 44 rows and the function computed all 1,044 again — two 226ms executions, most of the page's remaining 679ms. PostgREST applies `.gt()` to the function's RESULT, which is why it worked and why it did not help. As a parameter the bound reaches the scan: 5ms.

Two mistakes on the way, both worth the record.

`p_after is null or source_id > p_after` is not an index condition. The first branch matches everything, so the planner scans and filters exactly as before, and page two still cost 422ms to return five rows. `source_id > coalesce(p_after, '')` says the same thing in one comparison the index can serve.

And `CREATE OR REPLACE FUNCTION f(p text DEFAULT NULL)` does not replace `f()`. A default is not part of a function's identity, so that created a second function and `latest_source_runs()` became ambiguous — every caller of the no-argument form broken from the moment the migration applied, reporting success, with nothing saying so until a query happened to call it. That is the second migration today that succeeded while leaving something wrong; the first did nothing at all. Both now carry assertions, which is the only thing that would have caught either.

A budget that measures the shell is not a budget

07:02 pm AWST · 1f5e765

Every page here streams: the shell flushes immediately and the figures arrive when their queries resolve. So the proposed TTFB budgets measure how fast we can send a heading. /value's TTFB is 121ms, comfortably inside a 500ms budget, while the reader waits fourteen seconds for the valuation. That is not a lenient budget, it is a broken gauge.

check-budgets.ts measures both, and names them: TTFB for the shell, TIME TO THE ANSWER for the last byte, when every suspended figure has resolved. It reads the body as a stream rather than awaiting text, because awaiting the whole body cannot tell you when the first byte landed — which is the entire distinction. Median of three warm samples plus one cold, because the fastest sample flatters a cold path and the difference between cold and warm is itself the finding.

It immediately found two routes over, and both are now fixed.

/value, 14,067ms to answer. The page runs valueCar and depthForSubject in Promise.all and waits for the slower: valueCar is 940ms warm, depthForSubject was 14,335ms, and all of it was one count of live listings for the model. listing_current resolves each listing's newest observation and its whole jsonb payload per row — right for showing listings, absurd for counting them, 111,599 buffers to count 8,652 rows. And `ilike 'Ford'` is case-insensitive equality wearing a pattern's clothes, which no btree can serve, so it also seq-scanned 103,687 vehicles to find 7,422. A lower(make), lower(model) index and a function that reads listing and vehicle and nothing else: 110ms.

The page also had four sequential round trips where two would do. The catalogue slice depends only on the URL and now joins the first batch; the age ladder needs only the subject, and whether the valuation succeeded decides what is rendered, not what may be fetched.

/coverage, 1,058ms to first byte. latest_source_runs() takes the newest run per source twice over 143,463 rows, and the index ordered the scan while carrying none of the columns, so every qualifying row was a heap fetch. Two covering indexes: 957ms to 226ms, 278,360 buffers to 36,000. Chosen over a board deliberately — "is this source crawling right now" must not be answered by something fifteen minutes old.

Section 1: a figure that cannot be published without saying what it counts

06:50 pm AWST · 7ffec93

About 180 of the audit's 493 findings are one of four omissions repeated across every surface — a figure with no unit, no sample size, no date, or a blended median with no basis. Fixing 180 call sites leaves the 181st free to repeat it, so this makes the omission a type error.

<Figure> takes discriminated unions for provenance and dating: a caller supplies either the value or the REASON it is unavailable, and the reason renders. A developer in a hurry can leave a prop off; they cannot satisfy a union by leaving it off. <Basis> expands a price mix, and returns nothing when one basis holds everything, because a mix of one is not a mix and printing it every time trains the reader to skip the line that matters. <Window> prints a period's bounds, because "this week" means a rolling 7 x 24 hours on the terminal and Monday to Sunday UTC in the weekly report.

Applied first to the yard header, where a dealer looks. Every Stat now states its own denominator, and they are different denominators: a median ask is over cars with a price, a median km over cars with an odometer, a median age over cars whose seller publishes a listing date. The stock count is none of those and is the number that was to hand — publishing it would have been a specific wrong denominator, which is worse than none because it invites arithmetic. Two RPCs now return the counts they were already filtering to.

The lint took four attempts and the failures are the interesting part. A word list reported 26 findings of which 23 were fine — a confidence score beside "sample, tightness, recency", a duration reading "12s ago" — and a check that cries wolf that often is not run twice. Replacing it with "is there any prose near this number" then counted the TypeScript around the JSX, so `export function return number` read as prose and a planted bare figure passed. That was caught by planting one deliberately, which is now the check's own test: a check that cannot fail is worse than no check, because it is believed.

after I wrote it, SECURITY DEFINER and executable by the browser key. That is the entire argument for that check, and it has now paid twice.

And the cron slots are re-chosen from measured durations rather than from where jobs start. dealer-exit-fact runs 7.4 minutes, so :17 ran into relist-ledger at :21 — a slot picked believing it was clear that never was. The durations are written into OPERATIONS so the next one is picked from numbers.

The ladder writes down when a chart goes away, and a watched yard is crawled like one

06:33 pm AWST · 252b022

ladder_event has been in the schema since the charts plan and holds zero rows against 5,799 ladder states. Nothing writes it. So when a cohort's chart disappears there is no record of what it was, what it fell to, or which measure took it away, and we cannot tell a cohort that fell once from one that flickers weekly.

Written by a trigger on ladder_state rather than inside refresh_ladder. That upsert states its promotion condition three times already; a fourth copy inside it would be a fourth thing to keep in step. A trigger observes the change that actually happened to the row and has no logic of its own to disagree with. Verified both ways: a regression writes the measure that took the chart away, a no-op update writes nothing.

And dealer:lexusofperth had a portal account and no crawl_config.priority for as long as it had a portal — so somebody was looking at a page whose exits arrived twelve hours late, while the rule saying otherwise had been written weeks earlier for the first account and applied by hand. Flag set, approve-dealer.ts sets it on every grant, and an integrity check fails if a source with a live membership lacks it. Same shape as defects 7, 14 and 32: a rule that exists and nothing calls.

The daily series says which unit it counts, and on what evidence

06:27 pm AWST · c1d18ef

Two faults in columns that share a name across four tables.

UNIT. daily_market_rollup.sold is count(*) over the day's delisted LISTINGS, undeduped and including cars still advertised elsewhere we watch. daily_state_rollup.sold and daily_fuel_rollup.sold are one row per car with the no-live-sibling guard applied. Over the 21 recorded days that is 66,920 against 29,297 — the market row is 228% of the car count, under an identical name in the same schema. cuts is 109,883 events against 67,525 cars.

Nothing reads it today, which is why it survived: the reports take sales from their own facts and the evidence series only counts contiguous days. A column called `sold`, written every day, ends up on a chart eventually.

EVIDENCE. Neither carries a basis, so no median drawn from a rollup can state its mix while the home page defines a confirmed sale narrowly two clicks away.

The recorded columns are not redefined — DATA-QUALITY 15's rule is that a published series never silently changes meaning. New columns beside them, comments on the old ones saying what they actually count, the whole 21 days backfilled, and a check that fails a day missing them.

Written as a separate pass rather than inside write_daily_rollup, and not only for tidiness: that function may run for the snapshot's own day and no other, because its stock columns can only describe the current snapshot. These columns come from the append-only log, so they compute for any past day — which is what made the backfill possible at all.

Also 0261: 187 price changes were written by diffing two readings of different cars, carrying $984,812 of movement no dealer made. Marked, not deleted — the readings happened; the inference between them is what is false. The rule walks each affected listing's VIN timeline and voids only the events inside a swap gap, so the 118 genuine changes on those same listings are left alone. No counter excludes them yet, deliberately: that is the same question already open for the 1,884 flapping events, and answering it for one population and not the other would leave the site counting two kinds of bad event two different ways.

A discontinuity says so where the series is drawn

06:14 pm AWST · 0600d7c

On 29 August the lapse rule reclassified 10,174 listings in one pass, 6,582 of them in WA, against 437 to 1,326 on the days either side. Nothing about the market changed; what changed is which listings we could still watch. /market's week-against-week column rendered it as "WA −5,599 watched cars", and a reader had no way to tell that from a fall in stock.

Every honest statistical publisher marks this rather than patching it — the ABS marks a series break, Eurostat carries a `b` flag, the BLS prints a footnote at the discontinuity. Deleting the figure would be worse anyway: the observations are real and the log is append-only.

So a register of breaks, and every windowed figure returns the ones inside its own window. The RPC works out what "inside" means — the level columns span the lag and the flows span twice the window — because a page that had to derive that for itself would eventually get it wrong. Both tables on /market carry it: one of the two staying silent would read as a statement about the other.

A board that reads the live corpus is stamped when it read it

06:10 pm AWST · bd8baa3

Every depth-cycle board carries the same timestamp to the microsecond --- 02:25:00.021471 across model_live_board, aged_board, sale_speed_board and market_make_board --- because pg_cron runs the whole chain in one transaction and now() is transaction start. A board finished at 02:39 says 02:25.

The audit called this thirty boards. It is three. refresh_live_snapshot, refresh_depth_boards and refresh_model_chart_board all compute from live_snapshot, so their numbers describe the market as at the snapshot and stamping them with the snapshot's instant is provenance, not error --- changing those would make the dateline worse. The three that read listing_current live are the ones being stamped before the data they hold, and the pages printing those datelines say "right now".

Patched textually with an assertion rather than retyped. Three bodies of 1.6k to 5.2k characters whose only fault is one function call, and retyping them to change three tokens is how a transcription error ships inside a timestamp fix.

coverage_counts() now returns refreshedAt, closing the reader's half of 4.4: four pages quote its counters in the present tense off a board rebuilt every fifteen minutes, and the API page can now say how old "right now" is.

Recorded and not decided: sale_speed_board and sold_car_fact disagree by about a thousand cars on the population behind "median days to sell 37, over 16,584 watched used and demo sales", with the same median. The board filters for a published age and then takes one row per car, so a car keeps an age from an older advertisement of itself; the fact table takes the most recent listing first and drops the car if that one published none. Both are defensible; two definitions behind one sentence is not. It moves a published headline, so it belongs to section 1 rather than to a commit about timestamps.

The exit-fact job gets a timeout that takes effect

06:00 pm AWST · 0987fb0

Both scheduled runs failed at exactly 120 seconds. The function carries `SET statement_timeout TO '10min'` and that is not enough: Postgres arms the timer when the OUTER statement starts, so `select refresh_dealer_exit_fact()` is already ticking against the calling session's ceiling before the function's setting applies.

I wrote that fact into OPERATIONS.md an hour earlier, about the identical failure inside migration 0242, and then scheduled the job without it ten minutes later. Every other heavy job here already begins `set statement_timeout = '15min';` — sold-car-fact and depth-boards both do. The lesson is now written as "look at what the neighbours do", which is the form that would have caught it.

The slot moves as well: sold-car-fact starts at :03 and :33 and runs eight minutes, so :08 and :38 put the two heaviest readers of listing and listing_observation inside each other twice an hour. :17 and :47 are clear.

Second lesson recorded: cron.unschedule terminates a running job and deletes its job_run_details rows with it. The identity backfill was unscheduled two minutes into a half-hour run to stop it repeating hourly, and left nothing — no rows and no record it ran.

A page that changes which car it describes stops being diffed

05:49 pm AWST · 2d24cdf

Chasing why one listing's price chart spanned $12,500 to $47,990 turned up a URL that had been five different cars.

traralgontoyota /cars/demo-ash-slate-2026-toyota-hilux-13001930 carried observations of a 2024 Landcruiser, a 2020 Hilux, a 2022 D-MAX, a 2017 CX-5 and a 2020 Triton in nineteen days --- each with its own 17-character VIN, and each of which also has its own correct listing at that same yard under its own stock-number URL. We recorded it as one car whose price went $107,990 -> $27,000 -> $30,000 -> $12,500 -> $26,500 -> $47,990, and wrote four price_change events, $48,500 of movement no dealer made. A single day's window across the corpus finds four listings doing this, so it is not one bad page.

The implausibility guard did not catch it and could not. A 75% fall demands the detail page confirm, and the detail page confirmed: the page really does serve that price. It is confirming the price of a different car. The guard asks about magnitude; the question is identity.

store.ts already knew this in one place --- it refuses to match a listing on a dealer's stock number unless make and model agree, after one dealer numbered a Patrol and an X-Trail alike and the diff read as a $58,917 gain. The URL path had no equivalent, and fillVehicleBlanks writes a VIN only into a blank, so a VIN that DISAGREED with the one on file fell through and did nothing at all. Silently. That is the worst of the three available answers.

Now it is a case. The break is recorded where it is seen, the listing is not re-pointed (moving it would hand the new car this one's price history, the same fault in different clothes), the observation is still written because the page really did say that, and everything derived from comparing this reading to the last one is skipped: price, odometer, photos, description. The crawl run line reports the count, because a site whose slugs rotate does it to many pages at once.

The first detector asked the question directly --- did this listing's VIN ever change --- and could not be afforded: 30 seconds for one day's window and a timeout for the corpus. Recording it at the source turns the check into a read of a small table. The month of history already in the log is backfilled once.

A tape checks its guard at the moment it makes the claim

05:14 pm AWST · da73692

The crawl box's scheduled run failed check 15 overnight: one of the hundred cars on the home page's sale tape had a live listing somewhere else we watch. Nothing is broken. sold_car_fact applies the no-live-listing rule at BUILD time and is rebuilt half-hourly, so a car re-advertised in between sits under "latest confirmed sales" for up to thirty minutes. 24% of delistings have a live sibling somewhere, so the window is not rare, it had simply not been sampled. The tape returns at most 100 rows; re-checking costs at most 100 index probes and restates the rule where the page makes the claim.

The model tape had the same hole and a twelve-hour version of it, because model_chart_board copies sold_car_fact's answer forward from the 02:25 or 12:25 depth build. Three hours after a build, 14 of 9,896 tape rows across 88 models were live again — each one a named car at a named price that nobody bought, which is what the suite means when it calls this failure invisible on screen. The rows now come from model_sold_tape, keyed by the slugs the route already has so it runs beside the two board reads rather than behind them. The counts stay on the board: they are aggregates over thousands of cars where half a day is immaterial, and re-deriving them per request is what 0231 removed.

The board keeps its own copy of the tape, now marked in the type as not read, because removing it means rewriting a 300-line refresh for no gain and a silent stale duplicate is worse than a declared one.

A yard's record is a table, not a query run twice per request

05:07 pm AWST · 17bc84e

dealer_profile built its exits aggregate and its sold tape by scanning listing_current twice for the source with two correlated subqueries per row. Tony White Group: 13.5 seconds warm, 37 cold, for a page that is a list of counters. 203ms now.

The more interesting fault is what the speed work turned up. sold_car_fact holds one row per VEHICLE, `distinct on (vehicle_id) order by delisted_at desc`, carrying the source_id of whichever listing was delisted last. That is the right shape for "did this car sell" and the wrong shape for "what left this yard": a car that left branch A in June and branch B in August is an exit from both shopfronts and is credited only to B. At Tony White Group 722 of 3,042 proved-sold cars are credited to a different source id --- a 24% undercount in anything reading a yard's history out of that table, which the August statement proof sheet does. It is exact for Westside Auto, a single site, which is why the sheet looked right. It would have been wrong for the first group we billed.

So dealer_exit_fact: one row per (source, vehicle), built half-hourly beside sold_car_fact, registered in board_spec, and read by both the yard page and the proof sheet. Every bucket verified identical to the scan it replaces --- 3,273 exits, 3,042 proved, 231 still live --- before the switch.

It also settles a second vocabulary. dealer_profile tested `confirmed_via` for the two inferred methods while sold_car_fact tests for the two kinds of evidence and calls the rest inferred. They agree on all 64,868 delisted listings today, so nothing published moves; one of them had to go before they stopped agreeing.

The new table gets an invariant, because it is derived state behind a document a dealer keeps: no yard holds more rows than cars that ever left it. Measured against listing_event, not listing.status --- the first draft used the status and failed on two yards, both relists, reading the table's half-hour lag as an overcount.

its cumulative read keyed on delisted_at, which is not unique.

A count is what it counts, and a cap counts every call

04:48 pm AWST · 69278f0

The day-review's findings, worked from most to least wrong.

The /model sold tape printed "and 48 earlier" under a heading saying 1,114 record. The fifth instance of that defect and the one I made worse, having raised the cap from 100 to 60 on the same day I fixed the identical line on /dealer. It now reads the real count that was already one paragraph above it.

A yard's exits counted advertisements. 0232 added the whole-record aggregate without deduping per vehicle, so Tony White Group read 3,295 under the word "cars" against 3,273 cars, while the tape eight lines below it in the same function deduped and carried a comment saying why. The same aggregate listed only 'soft 404' as an inferred basis where the tape lists 'soft 404' and 'stock feed absence' --- so the first stock-feed delisting would have been proved sold in the header and unproved in the table on one screen. 0224 is titled "a new basis is taught to everything that reads a basis"; this was the reader that did not learn it.

The home page's "latest confirmed sales" tape carried nine unproved exits in its sixty most recent rows, three panels under the yellow box where the page defines confirmed sold. Publishing a definition and breaking it in the next section is worse than never defining it.

Movers said "1,103 cars cut" where 1,103 is repricings and 886 is cars. 0233 deduped the double count and left the word "cars" beside the number. Both figures are worth having, so the board returns both.

The /dealer scatter drew every dated exit while the sentence beneath it counted proved ones: Westside Auto, whose pages never say SOLD, showed a chart of dots above the words "0 of them carry the seller's own listing age".

/moving opened with 85%, 35 and 49 typed into JSX above a filter that changes all three --- pick WA and the real split is 82%, 37 and 79. The RPC returns the blend under the same filter as the table.

/listing captioned its price chart "across 300 observations" directly below a heading reading "the 300 most recent of 663". Its chart-proof sheet paged keyset-wise on delisted_at, which is not unique, so two cars delisted in the same second would drop one silently at a page boundary.

The registration cap counted only the lookups that found a car, because the table it metered is also the cache and a cache must hold successes only. A run of unknown plates therefore cost real calls and moved no counter, past both the per-account cap and the global ceiling that exists so the bill cannot run away unwatched. Attempts get their own table, backfilled.

And smaller: the sold figures come from a half-hourly table beside live counters and now say so; "a page that now answers 404" was written in three places where 28% of the proofs are 410; the welcome email described the sold method as "what left the market without a price cut", which is not the method, and told people no account exists until they click, which is false; a lookup-cap grant took a free-text email where 0227 put the account's own id on the request.

Closing a table is not closing a database

04:31 pm AWST · 859d8a1

An adversarial review of this morning's own security fix found the posture still open by two routes, with my check reporting green over both. Verified with the publishable key that ships in the browser bundle, both 200 with real rows:

GET /rest/v1/listing_current_state a listing id and its state POST /rest/v1/rpc/recent_sales the sold tape, with prices

listing_current_state is a VIEW, and 0225's check asked pg_class for relkind='r'. Views, matviews, partitioned and foreign tables were all outside it — and a view over a table is the likeliest thing to reopen the table you just closed.

Six SECURITY DEFINER functions were EXECUTE-able by anon. A definer function runs as its owner and does not care what the caller may read, so revoking the table underneath changed nothing at all: recent_sales() and days_to_turn() served the corpus straight through the revoke.

And the check's other half was inert. It read information_schema.role_table_grants, which shows only grants the CURRENT role can see, and check-integrity.ts calls it as service_role — so another role's grants were invisible and that clause could never fire. It asks has_table_privilege and has_function_privilege now, which answer for a named role whoever is asking, and which also catch privileges inherited through PUBLIC that the old form missed entirely.

Every relation and every function in public is now closed to both roles; re-probed and all 401. Safe to revoke every function because the browser client makes five auth calls and nothing else, and every data read in the app goes through the service-role client — checked before doing it, and the whole site, the valuation, both auth pages and the integrity suite verified after.

The lesson is DATA-QUALITY 7's, and it was mine six hours after I wrote the other kind, and it will report green while doing so.

OPERATIONS learns today's schedule and the three kinds of proof

03:50 pm AWST · 733723d

The cron table now lists every job with why it sits where it does, including relist-ledger at :21 and the reasoning that puts it clear of the crawl chain and of sold-car-fact. Plus the rule this morning cost me: a one-off job needs a slot it cannot meet itself in.

And exit_basis is documented where an operator will look for it, including the fact that unlisted is not evenly spread — Westside is 644 of 644 — which is the reason /moving discloses the blend rather than filtering to it.

/moving names what it measures, and keeps the dealers it would have dropped

03:47 pm AWST · 1cafcb6

The page called its figure "days to sell" over a population that is 85% proved sales and 15% soft-404 exits, where a page stopped describing a car. It now says "days a car was advertised before it left the market", which is what the number is, and states the blend.

The obvious change was to filter to proved sales, and it was measured before being refused. It moves the headline from 37 days to 35 --- and it drops 103 of 479 contributing dealers out of the sample entirely, because whether an exit can be proved is a property of the dealer's platform, not of how fast their cars sell. Westside is 644 of 644 unproved. Trading a stated blend for a hidden selection, to move a median by two days, is the wrong way round, and it is the cell-concentration problem the research pass raised: losing a fifth of the sources is a bigger distortion than the thing it fixes.

So the blend stays and the page says so, including that the unproved exits run slower --- 49 days against 35 --- because a reader who knows the mix can judge the number and a reader who does not cannot.

Third time this week the measurement has overturned the intended fix, after the price-flapping guard and the neighbours ordering. Worth naming as a measuring tells you which.

A board outside the monitor, and a ledger that stopped sixteen days ago

03:44 pm AWST · d4cb4d6

coverage_board was never added to board_spec. 0111 lifted the board list into that table so the request path and the integrity suite would read one cadence, and the board refreshed every fifteen minutes and quoted by four pages in the present tense was the one left out. Nothing could date it, nothing alarmed when its refresher stopped, and board_status() had no answer for it. In now, ageing at an hour and stale at three.

vehicle_relist_event's newest row was 2026-08-18 09:33. record_relist_events() has only ever been run by hand --- no cron entry, no place in the crawl chain --- so sixteen days of cars leaving and coming back were missing, while the yard portal quotes a calibration derived from it as though it were current.

Scheduled hourly at :21 and backfilled in the same breath: 6,288 events recovered, 1,672 rows to 7,960. Nothing was lost by the outage because the function reads only listing and listing_event, both append-only, which is what append-only is for --- sixteen dark days rebuilt exactly.

:21 deliberately. The crawl chain runs 03:43 and 15:43, sold-car-fact at :03 and :33, so this sits where it collides with neither. This morning I scheduled a one-off board rebuild at every minute for a refresh that takes six, and it overlapped itself and held locks that failed check_stale_boards twice; a periodic job wants a slot it cannot collide with.

A stock-status tile is not a car, and the check learns the rule with it

03:40 pm AWST · 897f613

Some dealer grids publish a "Used" card among their listings: a filter link wearing a listing's markup. The extractor reads it as a vehicle whose model is the word, and the corpus gains a car called "Ford Used". Nine of them reached /moving as nine sold cars with a median 171 days to sell, which is what a placeholder does --- it never leaves, so it ages forever and then turns up in a table about how fast stock moves.

Three conditions together, never the word alone. A model field is free text a dealer controls, so refusing every car whose model parsed as "New" would be a rule wider than its evidence. A real listing has a price; a real car mostly has a VIN; a tile has neither, and all nine have never carried a single priced observation in their history. The one "Jeep Unknown" that looks identical carries a VIN and is deliberately untouched: that is a model that did not parse, which is taxonomy work rather than a tile.

Marked ignored rather than deleted, which is what the status is for: the listings, observations and captures stay, because they are honest records of pages really fetched, and listing_current excludes ignored vehicles so every surface drops them at once.

The ingest rule ships with the back-fix rather than after it. DATA-QUALITY 20 is the entry about a backfill a running crawler quietly undid, and without isStockStatusPlaceholder in store.ts's outOfScope decision the next pass would have recreated all nine.

And the integrity suite caught me. It asserts every ignored vehicle is one the code's rules explain, so my UPDATE failed it as an unexplained hand-edit --- correctly, because I had added a fourth rule and not taught it. Its own comment says a check that does not apply the same rules as the thing it checks manufactures findings. Teaching it needed care in the other direction too: the tile rule's third condition is the price, which is not on the vehicle row, and checking only the model and the VIN would have let the check PASS something the ingest rule would have kept. So it asks the price question for the handful that reach it.

The movers board counts cars, and a flapping listing is reported not silenced

03:32 pm AWST · 6b3c7f8

Two things on the home page, and a guard refused on measurement.

The movers board counted listing_event rows, so one dealer decision arrived once per shopfront the car sits on: Ford Ranger's week read 1,813 events against 1,092 car-days. The pulse directly above it has deduped on (vehicle, UTC day, magnitude) since 0138, so the page printed one convention in its vitals and another in the board below. Now they share the definition, because two figures on one screen sharing a name must share a meaning.

Its `live` column was advertisements while the count beside it is cars --- Ranger 1,092 against 8,562 --- so it is renamed live_listings and the page says "cars cut" and "adverts". Renaming rather than converting: the board holds advertisements, and a column that silently starts meaning something else is how DATA-QUALITY 15 begins.

And the guard I set out to write is not there, deliberately. 43 listings have produced 1,857 changes between two or three prices each; one recorded two prices seven seconds apart on a $191,000 car, which is a page computing a drive-away figure that moves by $37 between reads. The obvious rule is to reject a change that returns to a price the listing already held. Measured before building it: 2,224 listings, 2.56% of all those with any change, do exactly that across 10,145 events, and the ordinary reason is a dealer discounting for a weekend and putting it back. Deleting 10,145 real events to remove 1,857 false ones is the wrong trade, and DATA-QUALITY 35 is the entry about proving an operation safe without asking whether it is worth doing.

So check_price_flapping() reports instead: many changes across very few prices, which no dealer behaviour explains. A warning, not a failure --- the events are honest records of what was read and the corpus is append-only, so there is nothing to revert. What is wanted is that the number stops growing unnoticed, and that whoever finds a real discriminator can see what it would be worth. 24 checks now.

The last three caps, and section 3 closes

03:23 pm AWST · 40754fe

source_max_found() returned exactly 1,000 rows against 1,010 enabled error, in the script that sets every source's detail budget. The symptom would have been the yards sorting last by id quietly never being fully crawled again.

pass_ordering_inputs() was already truncating. It returns 1,002 rows and the simulator read 1,000, so it modelled a smaller corpus than the one that runs. It now reports 1,002 sources and 232.2 worker-hours; the conclusion is unchanged, which is the right outcome for a measurement that was only slightly wrong.

/listing/[id] read its observation log and its event log unpaged AND with both errors dropped, which is two defects sharing one line: past a thousand rows PostgREST would have stopped silently while the heading counted the same truncated array, and a dropped error is indistinguishable from an empty result on a page whose empty state is ordinary. Both errors are read now, and the logs are bounded at 300 with head counts behind the headings, so a listing with 319 events says "the 300 most recent of 319". A cap that states itself is not a cap-as-total, and nobody needs a thousand rows in one table on a page that already ships 450KB.

That is section 3 finished: six page-level caps and the chart-proof route, with pageByKey now refusing a pageSize over 1,000 so the class cannot come back the way it came.

/value finishes in 17 seconds and its budget measures the first 300ms

03:09 pm AWST · 8988d83

Found while verifying the comparable-paging fix, and worth its own entry because the audit set this route's performance budget from TTFB --- "p50 under 300ms, measured 0.171 to 0.334s" --- which is true and is the wrong clock. The route streams, so the shell lands in under a second and the valuation, the only thing anyone opened the page for, arrives much later.

Measured in production: Jimny 53 comparables 1.7s, CX-5 168 at 7.3s, HiLux 440 at 10.2s, Ranger 650 at 17.5s.

Mostly not mine. HiLux holds 440 comparables before and after the paging change and takes 10.2s either way; the paging adds about 15% on the largest cohorts by fetching 43% more cars, which is the right trade and still leaves the number where it should not be.

The code's own comment says a valuation costs about a second whatever the cohort size, 434 comparables at 1,173ms, measured in August. DATA-QUALITY 17 again and this time in performance rather than in a guard: a number recorded when it was true and falsified by the corpus growing 45% underneath it. listing_current resolving fields per row is the likely cause, which is what 0060 and 0201 were both written about.

Recorded rather than fixed. A correctness fix that has been verified should not grow a performance investigation on the way out the door, and the budget itself needs rewriting first: measure the whole response, not the first byte.

The comparables are the set, not a thousand of it

03:04 pm AWST · f8e3cad

valueCar fetched the subject's model year with a bare .limit(1000) and no order by, which DATA-QUALITY 19 already names: a limit without an order by is a sampling decision, not a safety valve. Ford Ranger 2021 holds 1,270 eligible comparables and the query took an arbitrary 1,000, so which 270 were dropped was whatever the planner did that day. Paged now: 455 comparables became 650, and the mid moved $13, which is the point of 19's other half --- a biased sample of a large set lands near the middle, so the symptom is not a wrong number today but an arbitrary one that could be wrong on a thinner cohort tomorrow.

The comment above it justified the cap with "the largest cohort is 1,890 and only twelve exceed 1,000". Both true on 14 August, neither true now: 29 cohorts exceed 1,000 and the largest is 2,746, because the corpus grew 45%. That is DATA-QUALITY 17 exactly --- a guard whose justification quotes a number it does not own ends up guarding something else --- so the cap is gone rather than raised.

The eligibility filter also moved to the server. It ran in TypeScript after the fetch, so the row budget was drawn from every priced row the model-year ever had and only then narrowed to live-or-recently-delisted. That costs nothing today because no delisting has yet aged past 120 days, and would have started silently spending the budget on cars gone for months from about December.

I made it slower before I made it faster, and the numbers are worth keeping. Ordering the neighbouring-years query by last_observed_at looked principled and cost 626ms -> 4,088ms on a 53-car cohort: listing_current resolves fields per row, so an ordered limit materialises every row for the make and model across every year before taking 600. It was also the wrong sample, biased toward whatever the crawler touched most recently, which is a fact about our schedule. Ordering by uuid key instead is a stable arbitrary slice at 400ms, and 400ms is bought deliberately: the 600 bound binds on 107 of 1,451 models, which is the 7.4% people actually value, and an unordered limit there is a fresh draw per call --- two valuations of one car an hour apart differing with nothing behind it.

Ranger 2,846ms -> 4,808ms for 43% more comparables; HiLux 2,032 -> 2,326 unchanged in count. sanity-valuations largest gap 7.0%, inside its threshold, year-level path exercised.

Four digits get a separator, in the same sentence as their total

02:51 pm AWST · 7341820

The sub-counts beside a yard's exit total rendered bare while the total beside them was grouped: '3,305 cars ... 3071 confirmed sold'. Only visible once the counts stopped being capped at 200 and grew a fourth digit, which is its own small argument for the cap fix.

A yard's record is every car that left it, not the last two hundred

02:49 pm AWST · a9e383e

/dealer said "N cars watched off this shopfront" and then, under the tape, "and N-12 earlier" --- which reads as the complete record and was the RPC's limit 200 counted twice. The exit count, the days-to-sell median and the discount share were all derived from that array, so a busy yard's history read as 200 and its median was a median of its two hundred most recent exits. Tony White Group has 3,305 exits; Brisbane Cars 1,317; John Hughes 1,188.

The fifth cap-as-total, and the same remedy as /model an hour ago: the cap moves to the length of the displayed tape and every count is computed over the whole record, server-side. The tape footer now states what it is showing and the window it covers, rather than implying it is everything.

The page already had the right shape --- 0144c split confirmed-sold from no-longer-listed from still-for-sale --- so this is that shape finally computed over the right population. Tony White: 3,305 exits, 3,071 confirmed sold, 234 still advertised elsewhere, median 35 days over 3,065 dated sales.

Westside is the case worth reading. 644 exits, zero confirmed sold, 644 no longer listed, because that site keeps its pages up and never says SOLD. A page that showed "200 sold" there would have been inventing the yard's entire sales record. It now says exactly what we can prove, which is nothing, and says why.

Same fallback discipline as /model: the page reads the new aggregate if present and falls back to the tape if not, saying "most recent only" when it does.

A model's sales are cars that sold, not the last hundred adverts that ended

02:17 pm AWST · e003dfb

/model printed "Confirmed sales · 100 watched out of the market" for every popular model, and each word was wrong in its own way. The board scanned listings on status = 'delisted' and nothing else, so: no sold rule, meaning a syndicated car that left one shopfront while live on another counted as gone; adverts rather than cars, on a corpus where syndication runs 2.2x on popular models; and rn <= 100 rendered as the model's whole record, which is the fourth cap-as-total in this codebase.

Ford Ranger: the page said 100. There are 3,241 delisted advertisements, 1,382 cars that actually left, 1,114 proved sold, 863 carrying a date. The median days-to-sell was a median of the visible hundred; it is now 45 days over all 863.

sold_car_fact settles all three by construction, so the board reads it. The cap moves to the length of the displayed tape, which is what a cap is for. The payload gains `turn` because the counts used to be derived in TypeScript from the array's length, so capping the array capped the total.

Two deliberate bits of care. The page reads `turn` if present and falls back to the tape if not, saying "most recent only" when it does: between a migration and the next six-hourly rebuild the board has no such key, and a page that silently renders a smaller number for six hours is the failure this whole section is about. And the 268 Ranger cars that left without proof are printed as their own figure rather than folded in.

Recorded through the CLI ledger path rather than left as a function applied by hand: sync-migrations is in sync at 257 applied, 0231 on disk.

One mistake worth writing down. To rebuild the board before its 12:25 cron I scheduled a one-off pg_cron entry at '* * * * *' --- and the refresh takes six minutes, so a second run overlapped the first and held locks that failed check_stale_boards twice. It cleared on its own once the job was unscheduled. A one-off job wants a schedule it cannot overlap on.

The research pass lands, and it overturns some of its own conclusions

01:55 pm AWST · 6224b4e

79 agents: 11 cited sweeps, 10 market-idiom evaluations each put through a data refuter and a constraints refuter, 16 unrequested recommendations judged and refuted, and a synthesis. Section 11 of the plan was written from the sweeps alone and is now replaced with the verdicts as amended by refutation, because in several cases the refuter overturned the evaluation's own headline.

Two findings were large enough to become rules rather than items.

The first appears in SEVEN of the ten evaluations: a numerator drawn from what the crawler happened to re-read, over a denominator drawn from what is live at build time. It produced a wrong-direction movers headline, a coverage curve wearing the label of market breadth, and an outage that never happened. So rule 7: a figure describing movement compares the same cars at both ends of the window. It is a precondition for the terminal, not a feature of it.

The second is that cell concentration invalidates more comparisons than thin samples do. Every Tasmanian index cell is 29 to 42% one dealer; one source supplies 48.6% of TAS hatch exits in the week that passes the frequency rule. Rule 8 puts a top-source ceiling beside every n floor.

The verdicts themselves moved. Velocity is reframed from speed to composition, and its 29-day model spread is deleted as the demonstration. The index is refused outright rather than deferred, with a rung-0 "who is marking stock down" board in its place. Confidence and heatmaps-as-tables are the two that ship now, and heatmaps only as printed numbers: colour ramps and choropleths are refused permanently.

And the refuters caught a false confession before it reached a page. A recommendation claimed 55,597 of 71,424 delisted events carry no confirmed_via at all. Measured directly, zero do: 58,583 are the seller's own SOLD marker, 8,541 soft 404s, 4,321 hard 404s or 410s. It was a field-presence artefact, and publishing it would have been an apology for a defect that does not exist. That is what the adversarial pass is for.

A sold car carries the proof it was sold

01:53 pm AWST · d7564c2

Three kinds of evidence end a listing and this project already knew the the dealer profile label those exits unlisted. Every other surface went on calling all three "confirmed sold", because sold_car_fact never carried which one it was.

Measured across all 31,086 facts: 74.7% the seller's own SOLD text, 5.7% a page now answering 404 or 410, 19.6% a soft 404 --- a page that still answers 200 and no longer describes a car, which is a shape rather than a statement. Over seven days that was 23% of the cars and 26% of the dollars sitting under the stronger word.

Nothing is dropped; every row is a real exit. The row now says which proof it has, so the home page reads "10,354 cars, of which 7,990 sold and 2,364 left without proof" instead of adding them up. The yellow panel names the mix in its own words: 23,207 by the seller's text, 1,777 by a dead page.

The pulse also stopped rescanning the corpus. It read sold_cars off listing_current_lean with a correlated NOT EXISTS per row --- the shape DATA-QUALITY 44 says took the site down once, and which the boards audit measured as walking back toward its own timeout. sold_car_fact holds the identical population, so the pulse reads the table and carries its refreshed_at as sold_as_of, because trading a live scan for a half-hourly table inside a five-minute board is a silent regression unless the page can say how old the figure is.

0230 is the part that lasts. The pulse counts exit_basis <> 'unlisted', which is false for NULL, so a rebuild that stopped classifying would not print a wrong number: it would print zero cars sold, everywhere, which is DATA-QUALITY 8's absent-number-with-an-explanation exactly. NOT NULL plus a value check makes that state unreachable rather than reportable.

Backfilled before the next pulse tick rather than after, because the five-minute cron would otherwise have served that zero to the site for the gap. Verified end to end: board 7,990 sold and 2,364 unproved, the page rendering both, 23 integrity checks green.

Mail sends, and an account may deepen the picture rather than gate it

01:36 pm AWST · d69fc5b

spotlot.io is verified with Resend and Supabase sends through it as "Taj at Spotlot <no-reply@spotlot.io>" at 100 an hour, up from the built-in mailer's two. Proven by a real send that returned a message id, not by a config read. DKIM, SPF and the bounce MX are in Vercel DNS.

Two traps worth the comments they now carry: Resend has no Australian region, so the domain sits in ap-northeast-1, the nearest of the four it offers; and Supabase's schema wants smtp_port as a STRING, rejecting a numeric 465 with a message that reads like a fault in the caller.

The setup key was created full-access, used once, and deleted. It reached the shell from the clipboard and never appeared in a transcript or a file. The permanent credential stays sending-only.

Four copy changes from Taj. The contact is taj@dealerloop.com.au, which has Google MX and therefore actually receives, unlike the no-reply it sits beside. Dealerloop Technologies loses its inner capital. The signature gains a line about the company.

And one claim comes out, because it stopped being true the moment Taj said what he wants next: "nothing about the market hidden behind an account". He intends to show account holders deeper per-yard detail on the dealerships we watch, so the sentence had to go rather than be quietly falsified later.

That is a third limb on a rule I wrote two days ago, and it is the one that can drift into a tier by accident, so UPLIFT now carries its boundary. It is directional: an account may reveal detail that never existed publicly, and may never take away detail that did. Anything on a public page stays on it, and the counts that describe the market as a whole are public by construction. A corpus-wide median passes none of the three limbs, which is the line doing its job.

The welcome email wears the brand and admits it cannot be replied to

01:27 pm AWST · c557b6c

No mailbox, by decision: sending from spotlot.io needs only SPF and DKIM proving we may use the domain, so no-reply@spotlot.io costs nothing and scales as far as the provider does. Receiving is a separate problem and we are not solving it.

That leaves one honest obligation. Supabase auth has no reply-to field, so the From address IS the reply address, and the previous draft said "I read every reply" over a mailbox that bounces. It now says the address takes no replies and names one that reaches him: taj@proclose.com.au, the same address the crawler already publishes in its user agent, so dealers who want to write have somewhere real to write to. An email signed by a person from a mailbox that silently bins the answer is a small dishonesty and it is the kind this project does not get to make.

And it is in the brand now rather than in a neutral shell. Dark ground and card straight from globals.css so the two cannot drift, one accent, and the yellow button carrying black and nothing else. Two things email forces that BRAND cannot have: Archivo will not load, so the stack falls to the system face; and CSS is not a mechanism you can rely on, so every rule is inline and the dark ground is set with bgcolor attributes as well as properties, because the Word engine reads the attribute and ignores the property.

The mark is built from table cells rather than an image. Three rounded bars and a dot is simple enough to draw with backgrounds, and drawing it that way means it survives the image blocking that is on by default in most clients, where a hosted logo is an empty box and some alt text. Outlook ignores border-radius and renders squares, which still reads.

Rendered all three and looked at them. The From name becomes "Taj at Spotlot" when the full setup runs: a bare company name over a letter written in the first person reads as a template pretending to be a person.

A sending key should only send

01:13 pm AWST · b487007

The key Taj created is restricted to sending, which is exactly right for the credential that lives in .env.local and ends up in Supabase: SMTP only ever sends, so if it leaks it can send mail and cannot reshape the account, add a domain, or read the other keys.

It just cannot do the one-time domain registration, and a 401 whose body says restricted_api_key invites replacing a correct key with a more powerful one. So the script names that case specifically and asks for a second, temporary full-access key in the environment — never in a file, deleted afterwards — rather than upgrading the permanent one.

The balance is not ours to meter, and the welcome is from Taj

01:10 pm AWST · 2347bcc

Credits are topped up automatically now, so they stop being a gate. The honest way to do that is not to write a large number into vendor_remaining and pretend we know the balance: we do not know it, because the vendor's figure is not in anything we capture. So balance_tracked says false, credits_left reports null rather than a computed guess, and the two gates that rested on it stop applying. Set the flag and the column and they resume, with no code change.

The global ceiling changes job rather than value. It was a budget at 12 a day against 47 credits; it is now a runaway guard at 2,000 — there to notice a loop or a bug, not to ration a dealership, and sized so no plausible amount of real use reaches it. The per-account caps are

And the confirmation email is a message rather than a notice. It says thank you, says what Spotlot does and why watching a listing beats photographing it, says the data is free and stays free, and asks the reader to say what would help their dealership. Signed by Taj, founder, DealerLoop Technologies. The recovery and invite mails get the same shell kept deliberately short, because somebody resetting a password wants the link and not an essay.

Templates live in the tree rather than the Supabase console, because a template edited in a web console has no history and no way to tell what a person received last month. --templates pushes just the words, so a copy change costs no risk to a working mail path. They carry no figures: a corpus count in a template goes stale the day after it is written, and this project does not put a number anywhere it cannot trace, so the numbers stay on the site and the email links to them.

Pushed and verified live: subject, three intact {{ .ConfirmationURL }} placeholders, and the signature. They will send through the built-in mailer's two an hour until the Resend key lands.

Two emails an hour is a ceiling on how many people can have an account

01:03 pm AWST · a2ad57b

Not a rate limit. Every account here needs at least two emails to be useful — confirm the address, reset a forgotten password — so the built-in mailer's two an hour is the number of people who can ever hold one.

scripts/setup-email.ts does the whole thing in one command: registers spotlot.io with Resend, writes the SPF, DKIM and bounce-MX records into Vercel DNS (which is where the nameservers point, so the CLI that deploys this app can also do this), waits for verification, then points Supabase auth at Resend's SMTP and lifts the hourly limit from 2 to 100.

It stops short of exactly one step and says so in its own error: it does not create the Resend account or mint the key. An account is a person agreeing to someone's terms, which is not a script's to do. RESEND_API_KEY in web/.env.local is the whole of the manual part.

The limit is set to 100/hour rather than as high as it goes. It governs confirmations and resets; a number far above what a real afternoon produces buys nothing and makes a runaway loop expensive and invisible. At 100 the provider's own allowance binds first, and that one has a dashboard.

Provider choice is not load-bearing. Only the domain-registration call and the SMTP host are Resend's; Postmark, SES and SendGrid expose the same shape, so a swap is the PROVIDER block rather than the structure.

The confirmation email had nowhere to land

12:57 pm AWST · 0bd694a

Email confirmation was already required: mailer_autoconfirm has been false all along, so the thing that needed switching on was already on. What was not on was anywhere for the link to go.

site_url was http://localhost:3000 and uri_allow_list was empty. Every flow in this app passes an explicit redirectTo — the invite, the password reset, the new signup — and an empty allow list refuses each of them and falls back to site_url. So a confirmation link sent to a real person would have pointed at their own machine's port 3000. Both are now production, with the two dev ports kept so local work still completes a round trip.

One constraint remains and it is the one that decides whether the launch can be read. There is no custom SMTP, so the built-in mailer sends two emails an hour. Past that a signup fails, and the form said "check your email" to everyone by design, so a throttled afternoon would have arrived in our own numbers as "nobody signed up" rather than "we could not let them" — a false negative in exactly the measurement this is being opened to take.

So the form now separates the two. A duplicate address and a fresh one still get the identical answer, because telling them apart is how a customer list gets enumerated one address at a time. A rate-limited send gets its own message, because it says nothing about the person and everything about our mail service, and the honest thing to tell someone is that the address is fine and the service is busy.

The cap workstream records what it closed and what it did not

12:53 pm AWST · d48b95f

/coverage is fixed and deployed; chart-proof is fixed. Four page-level caps and two script-level reads remain open, and source_max_found() is named because it returns exactly 1,000 today and decides every source's detail budget — a figure sitting on the boundary is a figure about to be wrong quietly.

DATA-QUALITY 51: the fix for the row cap outgrew the row cap

12:51 pm AWST · b792ec4

/coverage said 1000 crawled against a true 1,044. Forty-four dealer sources rendered as "not yet crawled" — 55,410 live adverts, 24.3% of the corpus, including Autotrader, CarsGuide and the pilot dealer, every one crawled within the hour.

latest_source_runs() EXISTS because of this defect. 0081 was written when the page reduced every crawl_run in TypeScript and outgrew the same ceiling; its header even records that limit(8000) did not help, "the ceiling belongs to the server". Moving the reduction into the database changed how the rows were computed, not how many came back. One row per source, sources passed a thousand on 18 August, and the call has returned exactly 1,000 with no error every day since.

Nothing caught it because every symptom read as a fact about the world rather than about the read: a missing run row renders "not yet crawled", and the copy explained that as "it has not been visited", which was false for 44 of the 46 rows carrying it. The tell was a round number in an eyebrow rendered without a thousands separator, so 1000 did not look round. It does now.

Paged through pageByKey, which needed no SQL: PostgREST filters a set-returning function's result like a table's. The cost was measured rather than assumed — a function carrying `set search_path` cannot be inlined, so each page materialises all 1,044 rows and the page pays two executions, about 280,000 buffer hits each. Correctness first; the board that removes it is named in the entry and in UPLIFT so the second half is not forgotten.

An adversarial review over three lenses found the rest. The copy claimed two causes for "no crawl recorded" and there are three, the third being a source that blocks an honest crawler — the page's own headline promise in action. "with cars extracted" counted adverts, not cars. The counters come from whichever pass ran last, so 515 rows reading "21 / 21 / 100%" are one index page of a yard holding hundreds; the page now says so, which matters more now that the fix reveals 42 more such rows while removing the round number that made the truncation findable.

Two siblings, one live. /api/statements/chart-proof carried .limit(3000) on listing_event — inert against a 1,000 cap — with its error dropped. Westside's August holds 1,093 price events, so the proof sheet drew $990,601 of cut dollars against a true $1,084,201, understating the month by 8.6%. Both reads are now paged and neither swallows. The issued statement PDF was never affected; its own correct total is what showed the proof sheet was wrong.

And pageByKey gained the guard that makes the rule structural: its stop condition cannot tell the end of the data from a truncation, so it is safe at 1,000 and only at 1,000. A larger pageSize would have returned a truncation as a complete answer through the helper written to prevent exactly that. It throws now. The temptation was not theoretical — the paging cost above makes "just fetch 2,000 at a time" the obvious next edit.

A visitor may look up a plate, and a dealership may ask for more

12:45 pm AWST · 0913a8f

The second door. /request-access has always told visitors that an account cannot be self-created while the auth server accepted signups; this builds the door the copy implied instead of closing it.

Two doors, one boundary, unchanged. A dealer membership opens a named yard's own position and stays reviewed, because the review is what establishes the person works there. An account, by itself, opens only the things that cannot be anonymous: the registration lookup, which costs money per new plate, and per-person state. currentMembership() still returns null without a row and /yard still re-verifies, so a visitor account reaching a yard was and remains impossible.

The line, written into the migration so it survives this session: every figure the corpus produces stays public and ungated, forever. An account unlocks per-person state and calls that cost money per use, and nothing else qualifies on either limb. That is what stops "unlock a bit more data" becoming a tier by accident on a donations-only product.

Taj set 20 lookups a day for an account and no per-account limit for a verified Australian dealership, asked for through the same queue that grants a yard, because it is the same question about the same dealership. approve-dealer.ts --rego is the grant and it refuses without a note saying how the dealership was verified: this one costs money every time it is used, so a grant without a reason is not a grant.

The caps are data, not constants, because the balance decides them and the balance changes. 63 of 110 credits are spent; 47 remain. At 20 a day that is three accounts, so the global ceiling is 12 a day and the reserve floor is 15 — a bad day costs 12, and a dealership keeps working after a stranger cannot. Both are one UPDATE to raise after a top-up.

Two smaller truths fixed on the way. creditsRemaining was declared and never assigned, so the balance the code reasoned about was a literal 110 in a comment; the plan size is now a column and the fallback is the plan minus our own ledger, which is a figure we can defend. And the orphan warning in approve-dealer.ts counted every unlinked account as a person waiting for a yard, which self-serve signup would have turned into noise covering the real ones; it now counts only accounts that asked.

Not yet open: email confirmation is a Supabase auth setting, and the balance wants a top-up. The mechanism is ready for both.

Two front doors, and f109df0 goes live

12:25 pm AWST · e9d6740

Deployed the commit that had been sitting undeployed since yesterday afternoon; the lapsed-price qualifier it added is now on the listing page, verified by fetching one.

Then designed section 7a at Taj's direction: a self-serve visitor account that opens the registration lookup and a portable watchlist, beside the reviewed dealer account that opens a yard. /request-access already tells visitors an account cannot be self-created while the auth server accepts signups, so this makes the copy true by building the door it implies rather than by closing it. The dealer boundary needs no change: currentMembership() returns null without a row and /yard re-verifies, so a visitor account reaching a yard is already impossible.

The section states the line that keeps it honest, because "unlock a bit more data" is how a donations-only product grows a tier by accident: market intelligence stays public and ungated forever, and an account unlocks only per-person state and calls that cost money per use. The rego lookup passes on the second limb, the watchlist on the first, and most things will not pass, which is the point.

One measured constraint decides the build. RegCheck bills per distinct plate, the cache means a repeat never re-bills, and 63 lookups have been made by 3 reviewed dealers since 18 August against a per-user cap of 25 a day. That cap was written for four accounts; against open signup one scripted afternoon is the balance. So: a lower visitor cap, email confirmation, and a global daily ceiling on billed lookups, which is a spend decision and the one thing the section asks Taj for.

A table is closed unless it says otherwise

12:17 pm AWST · e4749c5

Eleven tables carried RLS disabled and the full default grant set, so PostgREST served them to the publishable key that ships in the browser bundle. Confirmed against production with that key before changing statement, every car named, to an anonymous reader, alongside sold_car_fact and market_pulse_board; listing and valuation returned 401, which is the intended posture working where it had been applied. The same eleven carried INSERT, UPDATE, DELETE and TRUNCATE, so the boards the site renders from were anonymously writable. Those grants were read from information_schema and never exercised.

0225 enables RLS on all eleven and revokes anon and authenticated, adding a twelfth — market_insight_history, whose RLS was already on but whose write grants were not — because a policy added later would silently open what the grant already permits. app_user, dealer_membership and dealer_price_entry keep their policy and select grant: that is 0178's design for the portal, where the policy is the filter.

The application loses nothing. Every data read goes through the service-role client; the publishable key is used in five places and all five are auth calls. Verified after: all twelve now 401, check_rls_posture() reads 0, the six busiest routes still serve 200, tsc and build pass, and the migration tree is content-exact with the ledger at 251.

The lasting half is the check. None of the eleven was opened by a decision; each was created without the two lines and nothing asked, which is defect 7, 14 and 32 again — a rule that exists and is never called. The posture is now asserted on every crawl, so the twelfth table costs a failed run the next morning rather than a month of being open. README and OPERATIONS described the posture in the direction that hid the gap; both now point at the control instead of restating the claim.

The RLS exposure, confirmed against production with the browser key

12:08 pm AWST · cbc33bd

A read-only GET with the publishable key from web/.env.local returns a dealer's statement from dealer_statement_archive, and rows from sold_car_fact and market_pulse_board, while listing and valuation return 401 as they should. The exposure is reachable from any browser, not only inferable from the grant table. Write grants were read from information_schema and deliberately not exercised.

The uplift program, for review

12:07 pm AWST · f2ca86c

docs/UPLIFT.md draws the 493 findings and the research sweeps into one sequenced program. Nothing is built; this is the plan Phase 3 owes.

The audit's finding, in one line: the numbers are mostly right and the screen does not carry what they mean. Four omissions account for about 180 of the findings, and all four are fixed once, by components that refuse to render a figure without its unit, n, as-at and basis, rather than at 180 call sites.

The program puts repair before uplift, because everything built on a wrong figure inherits it: the RLS exposure, the vocabulary layer, sold meaning sold, caps that are not totals, and ten data-quality repairs land before the chart kit, the reporting engine and the market terminal.

On "the stock market for cars", the research says the idioms survive and the instruments mostly do not. Velocity, movers with a floor, spreads, state tables and watchlists are honest today. Tickers and sparklines are gated on contiguous days. A published index is refused for now: 21 recorded days over a panel that went 49,673 to 227,302 live listings cannot support one, and the design work can start while the publication waits.

Section 11's verdicts will be refined when the ten idiom evaluations finish; the sweeps they draw on are already in docs/uplift/research/.

Phase 1 and 2 of the uplift program: what every surface actually says

12:02 pm AWST · b94c0c2

Twenty-one audited briefs, four persona walkthroughs and eight cited research sweeps, produced by parallel agents against the live corpus and the live site on 2026-09-02/03 at commit f109df0. Read-only: nothing in web/ or supabase/ is touched, no migration applied, no script run.

Each brief traces every figure on its surface to the SQL, board or RPC behind it, verifies a sample against the database with the statement recorded verbatim, and names the numbers whose provenance, unit, basis or freshness is not on the screen. The counts are large because the surfaces are dense: home alone renders 310 figures, 227 of which were re-derived.

Two findings are corpus-level rather than page-level and are filed on their own:

price-flapping.md — 43 listings have produced 1,832 price-change events between two or three distinct prices each, the worst 114 changes between two prices, still writing as of 2026-09-02 22:10 UTC. 1.42% of every price-change event in the corpus, concentrated at fifteen sources of which ten are BMW franchise sites, so it is one platform serving rotating documents on one URL rather than ten dealers repricing. It inflates every cut count and would corrupt the spread and repricing work the uplift plans. DATA-QUALITY 3 arriving where the document-selection guard does not reach.

browser-pass.md — the mobile, desktop and accessibility-tree pass the parallel agents cannot share, plus the fact that commit f109df0 is not deployed and the site carries no build stamp, so neither a reader nor an auditor can tell which commit is live.

Verification of the briefs is partial: the adversarial refuters and completeness critics completed for home, market, moving, coverage, model, listing, yard and dealer, and are outstanding for the rest, as are the cross-surface consistency map and the gap critic. Phase 2's evaluations of the stock-market idioms against the corpus are outstanding likewise. Both resume from cache.