# BidBrain project status, part 3: Mark's first sessions, v2 (19 to 20 August 2026)

Moved unchanged from CLAUDE.md on 2026-09-18. The standing rules stay in CLAUDE.md, which lists every part of this log.

  - Mark's first live session on his own accounts, 2026-08-19, several fixes:
    - login.py motorway false positive FIXED. The "logged in" auto detect checked host correct and "login" not in the URL path, but Motorway's own sign in page is at /signin, which does not contain the substring "login", so the check fired the instant the browser landed on the sign in page itself, before any real login happened, capturing a dead session. Fixed by also excluding "signin" from the path check. Caught because a background login.py run (no one at the keyboard) closed itself in seconds; always run login.py with Mark actually present to log in, never unattended.
    - Cazana cap REMOVED, Mark's decision 2026-08-19 (was: when Cazana is the only valuation and its calculated value is over 10,000, cap the governing value at 9,999, only Glass's could justify going over). Cazana now prices at its full calculated value (retail times 1.15) with no ceiling. pricing.py's Pricing.capped field and the capping block are gone entirely, not just disabled. New python3 daily_run.py --reprice re runs pricing on the cars from the last saved run (no re reading Motorway or Carwow, no re fetching photos, no fresh valuation lookups) for exactly this kind of pricing rule change; _load_cache now drops any stale keys a saved Pricing dict carries so an old cache never breaks a schema change. 106 tests pass.
    - Carwow test fixture FIXED and made durable. data/inspect/carwow_tab0.html was doing double duty as a live capture (overwritten by every login.py or reader session) and a permanent test fixture, which is exactly what broke it: Mark's own login.py carwow capture landed on the stock page and wiped the old won purchase snapshot the test relied on. Built a small synthetic fixture at data/fixtures/carwow_won_sample.html, shaped to match parse_won's expectations, and pointed test_pricing.py at it instead. Lesson: never point a test at data/inspect, that folder is scratch space anything can overwrite.
    - Cockpit Make, Model and Year filters ADDED to the control bar (render.py), alongside the existing search box and platform tabs, display only. Model is make specific (choosing a make rebuilds the Model list in JS to only that make's models) and groups by a clean base model rather than the raw derivative text Motorway's CSV bakes into the model field, via a new _model_family(make, model) in render.py: BMW's nnn codes become "1 Series", "3 Series" etc (X models stay as X1, X2...), Mercedes letter plus number becomes "A Class", "C Class" etc (multi letter codes like CLA, GLA stay as is), everything else takes the first word of the model text, with a small named exception list for two word models (Santa Fe, VW up!). Held back cards also carry make, model and year data now so the filters cover them too.
    - Carwow saved filter URL FIXED, was malformed. bidbrain/browser.py built the Carwow stock_url as a literal "?saved_filter_id=" base string plus, when CARWOW_SAVED_FILTER_ID is set, ANOTHER literal "?saved_filter_id=" plus the id, giving a single param whose value was the nonsense string "?saved_filter_id=<his id>" rather than a real saved_filter_id=<his id> query. Fixed to append ?saved_filter_id=ID only once. Tested live: the fixed URL and the broken one happened to return the same 39 cars at the moment of testing (both apparently fell back to the same unfiltered or equivalent list), so this was not the reason Carwow showed nothing on the cockpit, but it was still a real bug worth fixing before it silently returns something worse another day.
    - Carwow "nothing populated" explained, not a bug. All 39 Carwow cars read that day were legitimately excluded: 23 had no Reserve price published yet (Carwow shows only an Autotrader retail valuation, class listing-card-component__retail-valuation-value, on cards still data-listing-state="waiting_for_auction", not the reserve, which appears not to publish until nearer the auction), correctly rejected rather than priced on a guess per golden rule 4 (parse_listing only reads a figure when it finds literal "Reserve price" text, so it never mistakes the Autotrader valuation for the reserve). The other 16 failed real hard gate rules (mostly Land Rover and Jeep, banned makes, plus age and mileage), nothing to fix. Worth knowing: Carwow's next day stock genuinely does not always have a reserve yet when the scheduled run reads it, so some Carwow cars being held or absent on a given day is expected, not a read failure.
    - Cockpit server not actually live, FIXED. Mark said the page was not live; checked and found no process listening on 8765, no Tailscale installed, no cloudflared binary, and no launchd agents at all on this Mac, so there was never a real public link this session, only assumed continuity with Steven's old Mac Studio setup (a mistake, corrected). Set up com.bidbrain.serve as a launchd agent (~/Library/LaunchAgents/com.bidbrain.serve.plist, KeepAlive plus RunAtLoad, absolute python3.14 interpreter path since launchd does not use the shell PATH, logs to data/logs/serve.log) so serve.py stays up and restarts itself, proven by kill -9 on its process and watching launchd bring it back within seconds. Mark chose to do this step only, not the public tunnel, so the cockpit is reachable at http://127.0.0.1:8765/cockpit.html on his own Mac only for now.
    - Motorway saved search FIXED, was searching nationwide with no distance limit. The captured stock_url (browser.py) had no maxDistance parameter at all, despite the comment above it claiming "within 210 miles", and also differed from Steven's original brief (missing ageFrom, the grade filter, sellerType=private). This mattered specifically for distance: mileage, reserve, age, grade and owners are all independently re checked in pricing.gate_failures from the real CSV data regardless of the search URL, but distance is the one gate fully trusted to the source filter (Motorway's CSV only gives a location name, not exact miles, assume_distance_ok=True for Motorway in daily_run._assess), so an unfiltered search meant no distance enforcement at all for Motorway cars. Added &maxDistance=210 back to the URL and proved live it is genuinely applied, not a silently ignored parameter: 629 vehicles with no distance param, 499 with maxDistance=210, 2 with maxDistance=5. The other missing filters (ageFrom, grade, sellerType) do not need restoring since pricing.py's gate re checks them anyway, they were only ever a speed optimisation.
  - THIRD PLATFORM ADDED, Auction4Cars (auction4cars.com), 2026-08-19, alongside Motorway and Carwow. Logged in via login.py auction4cars (a real dealer login, "Hello Steve" on the dashboard is Right Drive's own director, not the original owner Steven, confirmed with Mark before proceeding). Reader is bidbrain/readers/auction4cars.py, built against real captured live pages (list and detail), same shape as Carwow: parse_listing reads the Cars tab list cards (make, model, derivative, year, mileage, transmission, fuel, location, starting price, listing link, from clean .vehicle-card-container markup), paginated by read_all_pages via a page number select dropdown (proven live: selecting page 2 loads a wholly different 18 cars, 31 pages, about 541 vehicles total), and enrich_from_detail visits each list gate candidate's own page for reg, owners, service history (derived from the Service History table, full only when every row is Main Dealer) and free text notes (the More Information paragraphs, SVA boilerplate line excluded). Wired into daily_run.py as _read_auction4cars, merged with mw_cars and cw_cars in both run() and review().
    - Three structural gaps against Motorway and Carwow, resolved with Mark before building: (1) NO RESERVE IS EVER DISCLOSED, only Starting Price and a reserve met or not met badge. Mark's decision: Guide Clean (the platform's own lower guide value, read from the detail page) stands in for Reserve everywhere, car.reserve is set from it in enrich_from_detail. Guide Retail is read but not currently used anywhere, flagged to Mark as a possible future signal. (2) NO 1 TO 5 CONDITION GRADE, only a defect map and tyre tread depths. Mark's decision: skip the grade gate for this source entirely, condition is judged by eye from the card. (3) LOCATION IS A DEPOT NAME (for example A4C-Castleford), not a postcode or mile figure. Mark's decision: skip the distance gate too, show the depot name as a location chip instead (car.location, the existing fallback already used when distance_miles is None). All three resolved the same way mechanically: pricing.gate_failures and assess() gained assume_grade_ok (mirroring the existing assume_distance_ok), and daily_run.py's _distance_ok_for(car)/_grade_ok_for(car) helpers decide both per source, used by both _list_gate_ok and _assess so the two never disagree. 122 tests pass, including a new "Auction4Cars reader" section against synthetic fixtures (data/fixtures/auction4cars_list_sample.html, auction4cars_detail_sample.html, never real captured stock, same reasoning as the Carwow fixture).
    - PRE EXISTING BUG FOUND AND FIXED while making _list_gate_ok source aware: it called gate_failures(car, today) with no assume_distance_ok at all, for every source, not just the new one. Since Motorway cars always have distance_miles=None (Motorway trusts its search filter instead of reading real distance), _list_gate_ok was rejecting every single Motorway car, proven directly against three real cars from a live shortlisted run (all three failed _list_gate_ok despite being genuinely shortlisted). Practical effect: mw_candidates in run() ended up empty, so the whole Motorway detail page pass (keeper start date, known mechanical issue, warning lights, private plate valuation fallback, photo backfill) has been silently not running, and the "fail loudly if the detail read gets almost nothing back" safety net never triggered because it only checks when there are 5 or more candidates. Mark chose to fix this in the same change rather than separately. Fixed by the same _distance_ok_for/_grade_ok_for helpers above, now threaded through _list_gate_ok too. Verified: the same three real cars now pass _list_gate_ok.
    - Auction4Cars notes on the card, a new generic mechanism, not Auction4Cars specific. Car gained a notes field (free text, flag only, never gates) and assess() appends "Seller notes: ..." to the flags when set. Deliberately not reused as mech_issue or electrical_issue since free text cannot be reliably auto classified into reject versus flag, so it always shows as a flag, never a rejection, letting Mark judge it himself.
    - Auction4Cars logo captured from images.auction4cars.com/a4c/img/resp-images/2022-a4c-logo.png (assets/a4c_src.png, assets/a4c_logo.datauri), same "used only to label the link to that platform" basis as the Motorway and Carwow logos. Unlike those two, the Auction4Cars badge already carries its own orange background baked into the image, so it sits on a white pill directly, no separate white variant needed for the CTA button.
    - No saved search or filter mechanism found for Auction4Cars. The page has Make, Model, Year, Location, Price, Mileage, Transmission, Fuel, Body type and Specification filter controls, but they are a custom JS widget, not URL driven (confirmed: selecting a filter did not change the address bar). Left unfiltered, same fallback already accepted for an unconfigured Carwow: the buying rules in pricing.py cull the full list, it is just slower (31 pages, about 541 vehicles, all read and gated before pricing).
    - Auction4Cars auctions do not sell the next day like Motorway and Carwow, they run 3, 5 or 7 days (Mark 2026-08-19, spotted live). parse_listing(page_html, today=None) now reads each card's data-auction-end-date (epoch ms) and keeps only auctions ending today or tomorrow, everything else is silently skipped, the same way Carwow silently skips a non auction listing state. read_all_pages(page, today=None, max_pages=None) threads today through and stops early once two pages in a row have nothing in the window (the list's own default sort is soonest ending first, proven live), a speed optimisation only, parse_listing's own date filter is what makes it correct regardless of sort order. daily_run._read_auction4cars passes today=date.fromisoformat(sale_date). Fixed the same day it was added: read_live's old "no cars means the page changed" loud failure would have wrongly fired on a genuinely quiet day (nothing ending soon), now checks for at least one readable auction id in the raw page instead of a non empty date filtered result. Verified live: 541 cars unfiltered fell to 298 ending today or tomorrow on a real run, 124 passed the list gate, all correctly enriched and gated. 138 tests pass, including a second synthetic card in the list fixture dated a week out to prove the window filter drops it.
  - SETTINGS PAGE for the buying rules, BUILT 2026-08-19 (Mark: rather than get a search filter URL from each auction house, most of which turned out not to expose one anyway, edit the rules BidBrain itself enforces). Covers the hard gate (max mileage, max reserve, min and max age, max distance, allowed condition grades, max owners) and the list shaped bans (banned makes, banned models, van and commercial model names, value caps, small city car tokens and their cap limit). Deliberately NOT covered: the pricing formula (FLAT_SPREAD, RETAIL_UPLIFT, GOVERNING_THRESHOLD) stays code only, a bit of friction on the real margin by design, and BANNED_ENGINES plus LITRE_BANS (32 structured rules with token matches, make restrictions and year windows) stay code only too, shown read only on the page for visibility, a naive editor for these risked a malformed rule silently letting a banned engine through. Motorway search URL auto regeneration from these settings was considered and deferred: the current URL's own mileage parameter is deliberately looser than the real 90,000 mile gate (Mark's own buffer), auto generating it would have silently collapsed that.
    - bidbrain/pricing.py gained DEFAULT_SETTINGS (a dict built from the existing constants, which stay as the factory defaults) and every function that reads a gate threshold or ban list (gate_failures, banned_make, class_exclusion, is_small_city_car, assess) gained settings: dict = None, merging {**DEFAULT_SETTINGS, **(settings or {})}. price_car and banned_engine are untouched, matching the formula and engine rules staying code only. settings=None behaves exactly as before, proven by test.
    - bidbrain/db.py gained a settings key value table (value stored as JSON so a row can hold a number, a list, or a list of objects) and get_settings()/set_settings(values), following the same connect/close/upsert convention as set_bid. set_settings only touches the keys given, a partial save (for example just Save on the hard gate section) never wipes the list shaped bans.
    - daily_run.py gained a cached _get_settings() (reads db.get_settings() once per process). _assess() and _list_gate_ok() both read it internally rather than taking it as a parameter, this was a deliberate choice after finding the assume_distance_ok bug earlier the same day, threading a new parameter through many call sites is exactly how that bug happened, an internal cached lookup cannot be forgotten at a call site.
    - serve.py: GET /api/settings returns {**DEFAULT_SETTINGS, **db.get_settings()} plus banned_engine_names (for the read only section) so the page always shows real effective values even before a first save. POST /api/settings validates before saving (numbers positive, min_age <= max_age, allowed_grades a non empty subset of 1 to 5, list items non empty strings, value_caps rows need a make and a positive cap) via a new _validate_settings(d), raising ValueError on anything wrong, caught by the existing generic exception handler and returned as the usual {"ok": false, "error": ...} shape, no new error plumbing needed. GET /settings.html renders bidbrain.render.render_settings() fresh on every request rather than writing a file, it has no run data dependency unlike cockpit.html so there is no reason for it to go stale.
    - bidbrain/render.py gained render_settings(), modelled on render_hidden()'s shape: fetch current settings on load, vanilla JS chip list editors for the simple string lists (banned makes, van models, small city car tokens), small table editors for the paired and structured ones (banned models, value caps with an optional model and exceptions), one Save button posting the whole object. A quiet "Settings" link was added to the cockpit's control bar (render_page) so it is reachable from the app, not just by typing the URL.
    - Verified live end to end: /settings.html shows the real current values, editing max distance from 210 to 250 and saving persisted through a fresh page reload (confirmed both in the browser and by reading the database directly), an invalid save (min age set above max age) was correctly rejected server side and did not touch the stored value, and a get_page_text check that first looked like empty value cap rows turned out to be a tool limitation (it does not read input value attributes), a live JS check against the actual DOM confirmed all six value cap rows were populated correctly throughout. Test values were cleared back to factory defaults (an empty settings table) afterward. 138 tests pass, including settings overrides for every gate and ban function with settings=None proven to match today's behaviour exactly, and a settings table round trip test in a temp database.
  - COCKPIT LAYOUT: search and filters moved into a left sidebar, 2026-08-19 (Mark, the top control bar had grown cramped: tabs, search, Make/Model/Year, sort, Settings, run buttons all on one wrapping row). The search box and every filter dropdown (Make, Model, Year, plus two new ones, Transmission and Body style) now live in a sticky `<aside class="sidebar">` next to the results; the brand, platform tabs, sort, Settings link and run buttons stay in the top `.controls` bar as navigation and actions, not narrowing filters. No `@media` rule existed anywhere in render.py before this (checked), added one at 860px so the sidebar stacks above the results on a phone instead of squashing them, proven live at the mobile preset. Body style is sparse (mainly Motorway) same as every other filter here, options are only ever built from what is actually present. New data-transmission attribute added to both the shortlist card and the held/rejected slim card (data-bodytype already existed on the shortlist card, added to the slim card too), and the JS okFilters in applyView extended the same way the existing four filters already worked, no new mechanism. 138 tests pass (rendering only, no gate or pricing logic touched). Verified live: both new dropdowns show real options and filter correctly, mobile stacking confirmed by resizing the browser to the phone preset.
  - VALUATION CACHE, 2026-08-19 (Mark: every re run was repeating every Glass's and Cazana lookup from scratch, and Auction4Cars' multi day auctions mean the same car can legitimately reappear across several days' runs, getting re valued each time for a retail figure that has not changed). New bidbrain/db.py table valuation_cache, keyed on reg, mileage and source (glass or cazana), so a car that has genuinely been driven since last seen still gets a fresh look up rather than reusing a now wrong figure. value is nullable, a confirmed no match is cached too, so a persistently unmatched reg is not re queried every day either. get_cached_valuation(reg, mileage, source, make, model, max_age_hours=48) only counts a row as a hit when it is within the window AND its stored make/model matches what is being asked for now, a cheap extra guard against a private plate having moved to a different car since it was cached. prune_old_valuation_cache() deletes anything older than 48 hours, called once near the top of run() (only listing data, reserve, mileage, bids, auction state, is still read fresh every run, only the valuation lookup itself is cached). daily_run.run()'s existing _try_glass/_try_cazana closures (with their circuit breaker) are untouched, wrapped by a new _cached_or_live(source, live_fn, reg, mileage, expect) that checks the cache first and only calls the real lookup on a miss, used at all four call sites including the private plate retry. A cache hit versus fresh lookup count is printed after the valuing loop. 147 tests pass, including a full round trip section (hit, miss, case and reg normalisation, make mismatch treated as a miss, a different source treated as a miss, a cached None, an artificially backdated row correctly treated as expired, and pruning removing only the expired row).
  - CARWOW PAGINATION BUG FOUND AND FIXED, 2026-08-19 (Mark asked why Carwow kept returning no cars). daily_run._read_carwow was only ever reading page 1 of the saved filter's results, a single page.goto plus parse_listing, never looping pages, despite carwow.read_live already having a proven pagination loop that daily_run.py simply never called. Checked live: the saved filter actually matches 530 vehicles, page 1 and page 2 have zero registration overlap (confirmed different real cars, not a repeat), so the daily run was only ever seeing about 7% of the real filtered stock. Fixed by giving carwow.py the same read_all_pages(page, ...)/read_live(playwright, ...) split Auction4Cars already has (read_all_pages takes an already open page so daily_run can reuse the same session for the owner/VAT detail enrichment pass afterward, read_live is the standalone convenience wrapper). daily_run._read_carwow now calls carwow.read_all_pages(page). Verified live: read_all_pages(max_pages=25) returned exactly 530 cars, matching the site's own count.
  - CARWOW REMOVED RESERVE PRICE, redesigned the same evening once the pagination fix above still showed 0 candidates (Mark: "they removed the reserve, use the CAP clean as the price"). Checked live and confirmed: the literal text "Reserve price" no longer appears anywhere on the list cards OR the car's own detail page, on any of the current stock, only an Autotrader retail valuation figure remains on the card (not the reserve, never used, same figure identified and correctly left unused in the pagination investigation above). CAP clean is still readable, but only on the car's own detail page (floating-box-waiting-for-auction-component__cap-clean-value, a fixed £ figure next to a "CAP clean" label, read via the same value_after() line pattern enrich_from_detail already uses for owners, VAT, keeper start), not the list card. carwow.enrich_from_detail now reads it and sets car.cap_clean, and stands it in for car.reserve too (the same "the platform's own lower guide figure covers for reserve" decision as Auction4Cars' Guide Clean), but only when a real reserve was never read, so a genuinely disclosed reserve (if Carwow ever shows one again, for example once a car's own live auction actually starts, a state not observable this evening since nothing was live) is trusted over this fallback, never silently overwritten by it. Since reserve is no longer ever known at list time, daily_run._list_gate_ok (the cheap pre filter deciding which cars are worth a detail page visit) now also ignores a missing reserve there specifically (matching how it already ignored missing owners), deferring the real check to after enrich_from_detail runs; a genuinely too high reserve still rejects immediately as before, only the merely unknown case is deferred, so this does not loosen anything for Motorway or Auction4Cars, both of which already carry a price figure at list time. Verified live end to end against the real site: of 117 cars read across 3 pages, 50 passed the (reserve deferred) list gate and every one enriched to a real CAP clean derived reserve (for example a Jeep Renegade at £4,700, a BMW X3 at £6,650, a Mercedes C Class Estate at £10,300), a dramatic change from the 0 candidates the pagination fix alone produced that same evening. 147 tests pass (this touches reading and gate timing, not the pricing formula or the hard gate thresholds themselves, no test regression).
  - DEALERKIT STOCK READ, BUILT and wired end to end, 2026-08-19 (Mark: "next up is dealer kit this is our DMS rather than click dealer... lets add this to bid brain", scope confirmed as stock read plus the monthly learning reports, stock read done first). DealerKit is Right Drive's real DMS, replacing Clickdealer (which Right Drive never used, STOCK_FLAGS_ENABLED had been off since handover). New bidbrain/readers/dealerkit.py: STOCK_URL is the Vehicles list filtered to retail market, stock asset class, life cycle status 10 (Mark's own saved Vehicles view, captured live 2026-08-19), spans all three of the account's locations (RightDrive Car Finance, STC Automotive, Kirks Works, Mark's choice). parse_stock(html) reads every span.vehicle-title ("MAKE MODEL...") plus the vehicle ids on the page (used to detect end of pagination), keyed with clickdealer.model_key (reused unchanged, first token of make plus first token of model) so a DealerKit "NISSAN QASHQAI" and a Motorway "Nissan Qashqai Tekna dCi" still meet on the same key. DealerKit's pagination is a client side SPA, page/per_page URL params are silently ignored (checked live), so read_all_pages(page) paginates for real: click the actual keyboard_arrow_right control and detect new content by vehicle id, not by any URL trick, stopping when a page repeats the same ids as the one before (a generous 50 page safety cap). read_in_stock(playwright) is the standalone convenience wrapper (same shape as Motorway/Carwow/Auction4Cars), fails loudly if no vehicle-title ever renders. Verified live end to end: 28 vehicles, 14 distinct model keys, matching the real site exactly.
    - Kept dealer specific: DEALERKIT_BASE_URL lives in dealer_config.py (gitignored, Right Drive's own tenant subdomain, kept out of this shared file) with a blank default in dealer_config.example.py, browser.py's SITES["dealerkit"] reads login_url/base_url from it, so a different dealership on DealerKit only ever edits their own dealer_config.py, never the shared reader or browser.py (the same isolation pattern as CARWOW_SAVED_FILTER_ID and MOTORWAY_STOCK_URL). Self caught and fixed before commit: the subdomain was first hardcoded straight into browser.py, which would have broken the shared repo for any other dealer.
    - Scheduled run: daily_run.py's stock read block (STOCK_FLAGS_ENABLED, now True) calls dealerkit.read_in_stock(p) in place of the old clickdealer.read_in_stock(p), same fallback to the last good snapshot on a failed read, same db.record_stock_snapshot. _stock_health_notice's banner wording changed from Clickdealer to DealerKit throughout.
    - On demand refresh: dealerkit_pass() (mirrors clickdealer_pass()/glass_pass()) opens a HEADED DealerKit window on the Mac via the shared _login_win_args, polls up to 300s for .vehicle-title, then calls dealerkit.read_all_pages(page) (not a single page.content() snapshot, an early version of this function only read page 1 of the list before this fix, caught before it shipped), snapshots the result, and rebuilds the cockpit from the last run cache, no re valuing. serve.py mirrors the Clickdealer plumbing exactly: _dealerkit_proc, /api/run-dealerkit (blocked while any other run is in flight), dealerkit_running in /api/run-status, included in _stop_run's kill list. render.py: a grey "Run DealerKit stock" button (leftmost of the run buttons, reusing the .clickbtn styling) posts to it and polls the shared progress bar (dealerkit-login added to ACTIVE_PHASES); daily_run.py gained a --dealerkit CLI flag. Verified live: the button starts the subprocess, opens a real headed DealerKit login window and reports the waiting to log in message on the progress bar, and Stop cleanly kills it leaving the existing shortlist untouched. 147 tests pass.
    - Still to do: the DealerKit monthly learning reports (sold vehicles, margins, SIV/prep cost, supplier) are not started, need a live exploration session with Mark logged into DealerKit, the same way clickdealer.py's parse_sold/parse_margins/parse_siv/parse_supplier/read_sales_month were originally built against real captured Clickdealer pages. monthly_run.py stays on Clickdealer (unscheduled per the handover note at the top of this file) until that work is done.
  - COCKPIT UI TIDY UP, 2026-08-19 (Mark: "lets fix the ui first, move the settings to the top right and the drop down for the best opportunity to the filter", then "now lets add some min and max filters for reserve, bid, guide"). Two render.py changes, display only, no gate or pricing logic touched:
    - Settings moved to the top right. In the control bar the "Settings" link now sits after the three run buttons (DealerKit, Glass's, Run now) rather than before them, so it reads as its own item in the far top right corner rather than being buried in the middle of the bar; given a small margin-left so it does not look glued to Run now.
    - Sort moved into the sidebar. The Best opportunity / Best match for you / Cheapest reserve dropdown (id="sort") moved out of the top control bar into the left sidebar, above the Make/Model/Year/Transmission/Body style filters, styled with the same .filtersel look. No JS changes needed, applyView() already reads it by id regardless of where it sits in the DOM.
    - Three new min/max range filters added to the sidebar: Reserve, Max bid and Retail guide (the "Retails for" hero figure, Pricing.governing_value). New _filter_range(id_prefix, label) helper builds a labelled pair of number inputs (id_prefix + "min"/"max"); a new inRange(val, min, max) helper in the page JS treats a car with no reading for that figure as failing an active range (never matches a filter on a value that was never read, consistent with golden rule 4 in spirit even though this is display only). The shortlist card gained data-governing (previously only had data-maxbid and data-reserve); held back cards gained data-reserve so the Reserve filter also works while viewing the Held back chip, but held cards have no data-maxbid or data-governing (they were never priced), so an active Max bid or Retail guide filter correctly excludes them rather than showing an unpriced car in a price range. Verified live against the real 318 car list: a £12,000 minimum bid filter correctly narrowed to 19 cars all at or above it, a £8,000 maximum retail guide filter to 73 cars all at or under it, a £9,000 minimum reserve filter to 58 cars all at or above it. 147 tests pass (rendering only).
    - Range filter labels clarified same day (Mark: "when i put 12800 in as a max bid it still shows me cars above this that so its not filtering to cars below my max bid"). The filter logic was correct throughout, reproduced live: typing 12800 into a row's first box (labelled "Min") shows cars AT OR ABOVE 12800, the second box (labelled "Max") shows cars at or below it, and Mark had used the first box expecting a single "my max bid" ceiling. Plain "Min"/"Max" are easy to mix up at a glance, especially on a row literally labelled "Max bid" where a reader's eye lands on the row label not the box label. _filter_range's placeholders changed from "Min"/"Max" to "At least" and "Up to" across all three range rows (Reserve, Max bid, Retail guide), no logic change. Verified live: typing 12800 into the "Up to" box under Max bid correctly narrows Motorway from 131 to 121 cars, the first card shown dropping from £12,919 to £11,469. 147 tests pass.
    - SORT DIRECTION TOGGLE added the same day (Mark: "now add a sort (lowest to highest) this is to control all filters", clarified via question to "its own toggle next to shortlist so that when the list is filtered you can sort the cars high to low"). A new "Low to high" chip sits in the filter row immediately after the Shortlisted star chip (both pushed to the far right by the existing margin-left:auto on .fchip.short, the new chip just follows it in the markup). Clicking it does not add a new sort criterion, it reverses whatever the sidebar's existing Sort dropdown already produced (Best opportunity, Best match for you, or Cheapest reserve), so it works uniformly regardless of which field is selected or which price filters (Reserve, Max bid, Retail guide) have narrowed the list. sortCards(vis, sort) gained a vis.reverse() at the end when the new sortDirReversed flag is set; toggleSortDir() flips the flag, toggles the chip's on class and aria-pressed, and swaps the visible label between "Low to high" (arrow up) and "High to low" (arrow down, rotated via CSS). Verified live: with Reserve filtered to 9000 and up and Sort set to Cheapest reserve, the list read 9026, 9071, 9100 before the toggle and 10985, 10967, 10846 after, label and pressed state both updated correctly. 147 tests pass (rendering only, no gate or pricing logic touched).
    - BEST OPPORTUNITY SORT MADE CAZANA CONSISTENT, same day (Mark: "the sort should always use the cazana value to keep it consistent"). The Best opportunity sort (and Best match for you's tie break) ranked cards by data-headroom, which is p.max_bid minus reserve, and p.max_bid is built off governing_value, which the buying brain deliberately takes from EITHER Glass's or Cazana per car depending which one qualifies (see the Cazana cap history above and pricing.assess). That is correct and untouched for the actual recommended max bid Mark bids from, but it means two cars' headroom figures were not always measured against the same valuation source, so ranking them against each other was not apples to apples. This is display ordering only, nothing here touches pricing.py or what max bid is recommended. _shortlist_card now also computes cazana_headroom = calc_cazana minus reserve (Pricing.calc_cazana was already a field, just not previously read by render.py), falling back to the normal headroom for the rare car where Cazana itself never read. The flat £3,000 spread is identical on every car either way so subtracting it first would not have changed the ranking, only the raw Cazana gap from reserve is needed. New data-cazanaheadroom attribute on the shortlist card; sortCards' 'opportunity' sort and 'match' tie break both switched from data-headroom to data-cazanaheadroom. Verified live: on the current all Cazana run (Glass's has not been run yet) the two figures differ by exactly the £3,000 spread on every sampled card as expected, and the Motorway section's Best opportunity order is confirmed strictly descending by the new figure across all 131 cards. The difference will only become visible once a mixed Glass's and Cazana run exists. 147 tests pass.
    - HEALTH NOTICES MOVED OFF THE TOP BANNERS, same day (Mark: "'None of these 318 cars have a Glass's value yet...' needs to be a red flag on the button to run with a roll over for this text do the same for dealerkit and then add a bell for noticaftions like 'no glasses setup'"). The permanent stack of amber banners across the top of the page (Glass's state, DealerKit staleness, Cazana health, the Glass's login countdown, all from daily_run's _session_health_notices/_stock_health_notice/_glass_login_notice, unchanged server side) is gone. New _categorize_notices(notices) in render.py splits them by keyword into glass, dealerkit and everything else, treating anything starting with "All " (the happy path, every car has both valuations) as informational rather than an alert. Run DealerKit stock and Run Glass's checks each gained a small red dot (.hasflag, a ::after pseudo element so it survives the button's own textContent being rewritten by setRunning while a run is in progress) plus the matching notice text as a native title tooltip (rollover), shown only when that platform has something to flag. A new bell icon (feather style SVG, _BELL_SVG) sits between Run now and Settings with a red count badge (hidden when there is nothing to flag) and a dropdown panel (#bellpanel, toggleBell(), closes on an outside click via a document click listener) listing every notice in full, including the informational "All N cars have both valuations" ones which never reden a button. Verified live: with the real 3 notice last run (no Glass's yet, no DealerKit stock yet, no saved Glass's login), both buttons showed a red dot with the correct notice text on hover, the bell badge read 3, and the panel listed all three with the two genuine alerts picked out in amber. 147 tests pass.
    - SETTINGS ICON, AND THE PRICING FORMULA MADE EDITABLE, 2026-08-20 (Mark: "change the word settings to a spanner icon, also add in the ability to change the LTV from 15% and the margin from £3000"). Two changes:
      - The text "Settings" link in the control bar is now a spanner icon (a feather style wrench SVG, _WRENCH_SVG, matching the bell's icon button styling, 34x34 with a border), title and aria-label still say "Buying rule settings" so it is still announced properly and still has a hover tooltip.
      - The pricing formula (what Mark calls the LTV, the 15% uplift added to Glass's and Cazana retail, and the £3,000 flat margin subtracted to get the max bid) is now editable from the settings page, REVERSING the earlier deliberate decision (see the settings page build entry above) to keep FLAT_SPREAD and RETAIL_UPLIFT code only as friction on the real margin. That friction was Claude's own build time judgement call, not a standing instruction from Steven or Mark, and Mark has now explicitly asked to remove it for these two figures. GOVERNING_THRESHOLD (the £10,000 point Glass's has to clear to govern) and the engine ban rules stay code only, unchanged, only the uplift and the spread moved. pricing.DEFAULT_SETTINGS gained retail_uplift (stored as a percentage, 15 means 15%, matching what the page shows and edits) and flat_spread (plain pounds). price_car(car, settings=None) now takes settings, merges it the same way every other pricing function already does, and uses s["retail_uplift"]/100 and s["flat_spread"] instead of the module constants; assess() now passes settings through to price_car (previously called with no settings at all). serve.py's _validate_settings gained checks for both (positive numbers, retail_uplift capped at 100 since anything higher is almost certainly a mistake). render_settings() gained a "Pricing formula" card, deliberately first on the page since it is the most consequential setting, with a note that it controls the actual recommended max bid on every car. Verified live end to end: raised to 18% and £2,500 and saved, confirmed in the database directly, then ran python3 daily_run.py --reprice (re prices the cached list on current settings with no re reading) and a real car's Cazana calculated value and max bid moved by exactly the formula predicts (13843 at 18% became 16335, minus 2500 spread gives 13835), then reset back to 15% and £3,000 and re repriced to restore the real figures. 150 tests pass, including two new pricing formula override checks and a settings none still matches the module constants regression check.
    - SORT DIRECTION CHIP REDESIGNED, same day (Mark: "the sort high to low still doesnt sort the cars by the highest cazana price to the lowest, this should be regardless of any filter being applied"). The chip built the previous session ("Low to high" next to Shortlisted) just reversed whatever the Sort dropdown had already produced, so with Best opportunity selected, "High to low" showed the LOWEST headroom first, not the highest Cazana price, exactly the bug reported. Asked Mark to confirm the intended relationship to the Sort dropdown; his answer: "its own separate control, dropdown untouched". Redesigned as a genuinely standalone sort, cycling off, low to high, high to low on each click (priceSortMode in render.py's page JS, null/'asc'/'desc'), always ordering strictly by the raw Cazana retail price (new data-cazanaprice on the shortlist card, calc_cazana falling back to governing_value for the rare car Cazana itself never read, same fallback pattern as cazana_headroom). sortCards(vis, sort) now checks priceSortMode FIRST and returns early on that basis alone when active, completely bypassing the sort argument (and so unaffected by whichever price filters have narrowed the list, exactly as asked). The Sort dropdown's onchange (now onSortChange()) resets priceSortMode back to null whenever a dropdown option is picked, so the two controls hand off cleanly rather than fighting over which is in charge; clicking the chip a third time (off) hands control straight back to whatever the dropdown already says, verified live to correctly resume Best opportunity order. Verified live end to end on the real list: cycling the chip gave the lowest Cazana price (£4,092) first in the "low to high" state and the highest (£17,228) first in "high to low"; with Reserve filtered to 9000 and up (58 cars) the same chip still correctly produced a strictly descending Cazana price order across the filtered set; picking Cheapest reserve from the dropdown while the chip was engaged correctly switched control back to reserve ordering and unpressed the chip. 150 tests pass (rendering only, no gate or pricing logic touched).
    - MAKE AND MODEL FILTER GROUPING FIXED, 2026-08-20 (Mark: "make the model specific the make ie a class and c class only show against mercedes"). Two separate bugs, both display only, neither touches pricing or the gate: (1) The Make dropdown split one real make into several entries whenever platforms spelled it differently (Mercedes, Mercedes-Benz and Mercedes-benz were three separate options; CITROEN/Citroen, KIA/Kia, MINI/Mini, SEAT/Seat similarly), so choosing "Mercedes" only showed cars whose OWN source happened to use that exact spelling, missing the "Mercedes-Benz" and "Mercedes-benz" cars entirely, which is what made A Class and C Class look unreliably scoped. (2) _model_family's Mercedes branch only recognised a derivative dump model text like "A 200 Sport Executive" (a letter then a digit); a source that already gives the clean name directly ("A-Class", "C-Class Saloon", which Carwow does) fell straight through to the generic first token fallback and came out as "A-Class" (hyphenated, one word), never merging with plain "A Class" even after fix (1). New _canon_make_map(makes) in render.py collapses every raw spelling onto one canonical display label: a hardcoded "Mercedes-Benz" for any Mercedes spelling (cannot be derived by a generic rule since neither "Mercedes" nor "Mercedes-Benz" alone looks like the odd one out), and for everything else, whichever variant is not fully upper case (a shouty CSV export, not the brand's own styling) when there is more than one variant to choose between. Computed once in render_page from shortlist plus held cars and threaded through _platform_sections, _shortlist_card and _plain_card (each gained an optional make_canon parameter) so the Make dropdown, the Model dropdown's grouping, and every card's own data-make attribute for filtering all agree on one canonical make. _model_family gained a second regex (`^([A-Za-z]{1,3})[\s-]?Class\b`) tried before the digit pattern, so "A-Class" and "C-Class Saloon" now both produce "A Class" and "C Class" same as the derivative dump style; GLA and CLA (genuine model codes, not "X Class" names) are unaffected, still their own group. Verified live against the real list: the Make dropdown now shows a single "Mercedes-Benz" entry, selecting it shows a clean six option Model list (A Class, B Class, C Class, CLA, E Class, GLA) with all 30 real Mercedes cars correctly tagged data-make="Mercedes-Benz", and filtering to A Class specifically returns all 8 real A Class cars from both source styles together. 158 tests pass, including 8 new checks pinning the canonicalisation and both Mercedes model text styles.
    - RESET FILTERS BUTTON, same day (Mark: "the filter needs a reset button"). A quiet "Reset" link sits beside the "Search and filter" heading in the sidebar (.sidebar-head, a flex row). resetFilters() clears the search box, sets Sort back to Best opportunity, turns the standalone Cazana price sort chip off if it was engaged, clears Make (which also rebuilds and clears Model via the existing updateModelOptions()), Year, Transmission, Body style, and all six Reserve/Max bid/Retail guide range boxes, then re runs applyView() once. Deliberately scoped to the sidebar only, the platform tabs (All/Motorway/Carwow/Auction4Cars) and the All/Gap fillers/Auto only/Held back/Shortlisted chip row are separate controls outside "Search and filter" and are left as they are. Verified live: set search, Make, Model, a Reserve minimum and the Cazana price chip all at once (0 cars matched), clicked Reset, every control returned to its default and the full 317 car list reappeared.
    - AUCTION4CARS COUNTDOWN, 2026-08-20 (Mark: "for the auction4cars vehicles they have a specific time of finishing not 3:30 like motorway and carwow, can we add the time of finish, counting down?"). Motorway and Carwow both close everything at one fixed daily time, so there was never a reason to show a clock; Auction4Cars lots run 3, 5 or 7 days and each closes at its own time (auction4cars.py already read data-auction-end-date per card to decide whether a lot was ending today or tomorrow, but threw the actual time away afterwards). Car gained auction_ends_at (an ISO datetime string, empty for every other source), set in auction4cars.parse_listing from the same end timestamp already being read. _shortlist_card adds a badge (.hf-gap.ends, topmost in the photo badge stack since it is time critical) with a data-endsat attribute and a static "Ends soon" placeholder; a new page level paintCountdowns() function in render.py's JS finds every one of these on load, computes the real remaining time client side, and repaints it every 30 seconds as "Ends in Xh Ym" (or just "Ends in Xm" under an hour), switching to a red .urgent style under an hour and a grey "Auction ended" .over style once it has passed, all without a page reload. Verified with a synthetic three car page (3h19m out, 44m out, 10m past): all three states rendered and coloured correctly, confirmed live in a browser (not just a unit check). 159 tests pass, including a new check that a real fixture card's auction_ends_at carries the correct ISO timestamp through parse_listing.
    - AUCTION4CARS REAL RESERVE FOUND, same day (Mark: "for auction4cars vehicles in the html you can read the reserve price example priceCon.data('reserve', 5000.0000); use this as the reserve shown"), CORRECTING the 2026-08-19 assumption logged above that this platform never discloses a reserve. It does, it is just never rendered as visible text: each vehicle's own detail page carries an inline script setting up its price widget, priceCon.data('reserve', N) (a second line, priceCon.data('total', N), is the current bid total, not the reserve, left alone). New _real_reserve(page_html) in auction4cars.py reads it with a small regex, returning None (never a guess) if the script is not found on a given page. enrich_from_detail now sets car.reserve from this first, falling back to Guide Clean (the old stand in) only when the script is missing. Verified against a real captured detail page (auction id 526045): _real_reserve correctly read 5000, matching the page's own source exactly. The synthetic fixture (data/fixtures/auction4cars_detail_sample.html) gained the same inline script (reserve 3450, deliberately different from its existing Guide Clean of £3,100) so a test can confirm the real reserve wins. Practical effect: Auction4Cars cars are now gated and shown on their genuine reserve rather than a Guide Clean valuation standing in for one, which was always a looser approximation. 162 tests pass.
    - CARWOW CARD WORDING: "Trade value" not "Reserve", same day (Mark asked whether Carwow has a hidden reserve too like Auction4Cars did; checked live and confirmed no, see below, then: "ok so on carwow cars it shouldnt say reserve it should say trade value"). Carwow's card still stores CAP Clean in car.reserve as a stand in (the 2026-08-19 decision, unaffected), but the card was labelling it "Reserve" exactly like Motorway's and Auction4Cars' genuine ones, presenting a valuation as if it were the real thing. _hf_gauge now checks the platform (_platform_class(car.source) == "carwow") and swaps every reserve facing word on a Carwow card: the gauge's bottom left label ("Trade value" not "Reserve"), and the room pill ("£X under trade value" / "£X over trade value" instead of "under reserve" / "over reserve"). daily_run._reserve_drop_flag does the same for its card flag ("Trade value dropped £X since last seen" for Carwow, "Reserve dropped" for everyone else); render._INSIGHT_DROP (the regex that turns that flag into a styled card row) widened to recognise both wordings so the Carwow phrasing still gets its "good" green styling rather than falling through to a generic amber row. Motorway and Auction4Cars, both genuine reserves, are unaffected. Verified live: a real Carwow card read "TRADE VALUE £8,550" and "+£3,570 room" (no mention of "reserve" anywhere on it), a real Motorway card alongside it still read "RESERVE" as before. 166 tests pass.
      - CARWOW HIDDEN RESERVE CHECK, same conversation, before the wording fix above (Mark: "can you check carwow html for any reserve price"). Result: no, unlike Auction4Cars. Checked a real live stock list (39 cards) and a real detail page: the only "reserve" text anywhere is a CSS class, listing-card-loading-state-component__reserve-price, a permanent empty skeleton placeholder, not a value that fills in later (re fetched after an extra 8 second wait, byte identical, so not a timing race either, unlike the DealerKit reports issue the same session). All 28 inline scripts on the detail page checked for a price/reserve style blob, none found. Every card checked was in data-listing-state="waiting_for_auction" (today's sale had not started yet), so a card mid live auction was never directly observed, left as a caveat rather than a certainty. CAP Clean stays the correct stand in.
  - HARD GATE MADE PER PLATFORM, PLUS AN AUCTION4CARS DEPOT ALLOW LIST, 2026-08-20 (Mark: "in the settings we need to make the hard gate rules specific to the auction, add a toggle to move between them and add specific options like location for a4c being newport, birley etc so that we can no see cars at branches to far away"). Two decisions confirmed with Mark before building: all six hard gate fields (mileage, reserve, age, grade, owners, distance) become separately editable per platform, not just distance; and the new Auction4Cars location control is an ALLOW list (only listed depots show, an unlisted or unread one rejects), not a block list, so an unrecognised depot is never silently let through, matching golden rule 4 (only Mark's explicit choice keeps something the settings page's own reset button always leaves at its safe empty default). The list shaped bans, small city car rule and the pricing formula (retail_uplift, flat_spread) stay shared across every platform, untouched, only the hard gate itself became platform specific.
    - pricing.py: DEFAULT_SETTINGS lost its flat top level max_mileage/max_reserve/min_age/max_age/max_distance_miles/allowed_grades/max_owners keys, replaced by DEFAULT_SETTINGS["platforms"], a dict of "motorway"/"carwow"/"auction4cars", each starting equal to the same factory defaults (_HARD_GATE_DEFAULTS) so nothing changes until Mark edits one. Motorway and Carwow each keep max_distance_miles; Auction4Cars has no exact mile figure at all (only ever a depot name, see auction4cars.py), so it gets allowed_locations instead, empty by default meaning nothing is enforced yet, exactly the pre existing skip behaviour. New _platform_settings_key(source) normalises car.source ("Motorway", "Carwow", "Auction4Cars") to the matching key, falling back to "motorway" for anything unrecognised so a future new platform is never accidentally exempt from the gate rather than silently unrestricted. gate_failures(car, ..., settings) now resolves every hard gate value through this per car lookup (a small pval(key, default) helper reading the resolved platform dict, falling back to the module constant if a key is somehow missing); the new depot check compares car.location against allowed_locations case insensitively (via the existing _norm()), since a human typing "Newport" into settings should still match whatever exact casing a source's own page text uses.
    - serve.py's _validate_settings was rebuilt around _validate_platform_gate(pd, platform_key), called once per platform, each error naming which platform it belongs to (for example "Carwow max reserve must be a positive number") rather than a generic message; Auction4Cars' branch validates allowed_locations as a list of non empty strings, allowed to be empty (that is the valid "not configured" state, unlike every other _str_list in this file which requires at least one entry).
    - render.py's settings page: the "Hard gate" card gained a three way toggle (Motorway / Carwow / Auction4Cars, .platform-tabs / .ptab, matching the --motorway accent already used elsewhere on this page) sitting above the same six input fields as before, which are now repainted from whichever platform is selected rather than being one shared global set. Switching tabs calls syncGate() first (reads the currently visible fields back into S.platforms[the outgoing platform]) so nothing typed is lost mid edit, then paintGate() fills the fields from the newly selected platform. On the Auction4Cars tab the Max distance field and the condition grades checkboxes hide (grade has no effect there regardless of this setting, assume_grade_ok is hardcoded True for this source in daily_run.py) and a new "Allowed depots" chip list takes the distance field's place, wired with the same chip add and remove pattern as Banned makes (wireAdd gained an optional lower parameter, false for depots specifically, so "Newport" is not forced to "newport", casing is only normalised for comparison, not for what Mark actually sees on the page). Old stale flat settings rows (max_mileage etc, from before this change, saved earlier the same session while testing something unrelated) were cleared from the database directly since pricing.py no longer reads them, nothing else needed the migration.
    - Verified live end to end through the real cockpit and settings page: switched tabs and typed different values into Motorway (95,000 miles) and Carwow (£9,500 reserve) without saving, confirmed both survived switching away and back (proving syncGate); added two depot chips (Birtley, Newport, exact case preserved) on the Auction4Cars tab; saved and confirmed the database held all three platforms' values correctly, independently; then ran assess() directly against three real shaped cars confirming an unlisted depot rejects with "Depot Faraway is not in the allowed depots.", a listed depot (Birtley) clears the gate, and Motorway's lowered 95,000 mile limit correctly rejects a 96,000 mile car while leaving Carwow and Auction4Cars unaffected. Test values cleared back to the empty "not configured" state afterward, matching the settings page's own safe default. 171 tests pass, including a full new section covering platform isolation (a Motorway only override never affects a Carwow car) and all four states of the location allow list (empty enforces nothing, an unlisted depot rejects, a listed depot passes, a missing depot rejects once configured, never guessed close enough).
