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.