# BidBrain project status, part 8: DealerWay, per platform runs and the daily schedule (24 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.

  - A FIFTH AUCTION PLATFORM ADDED, DealerWay (dealerway.co.uk), 2026-08-24 (Mark: "lets add another auction house ... this is similar to a4c and dealer auction"). Real reconnaissance done first as always: `python3 login.py dealerway manual`, Mark logged in and navigated to the real Listings screen, captured to data/inspect/dealerway_tab0.html plus one real car's own detail page (BU19DDL).
    - THE SITE IS A REACT NATIVE WEB APP, a genuinely different, harder shape to every other reader in this project: every class name is an auto generated atomic utility ("css-175oi2r", "r-1mtiv16"...), meaningless and unstable, no embedded JSON state blob, no data-testid on the real cards. Whole document HTML regex (the pattern every other reader here uses) was tried first and found to misalign across cards on a flat pass, caught by cross checking a naive regex's own colour extraction against a properly DOM scoped one and finding the two disagreed. Settled on a different technique for this one reader: a small JS function (`_EXTRACT_JS`) runs IN the page itself via `page.evaluate()`, walks the real DOM structurally (each card is one real `<a href="/listings/<id>">`, confirmed live, not guessed) and returns one plain dict per card, its own visible text (line by line, `a.innerText`, the same reliable shape already proven for LE Capital's plain text parsing) plus each of the three status icons' own real colour, read directly rather than through a class name. `parse_card`/`parse_listing` then do pure, testable line by line parsing of that already structured data, no raw HTML string parsing anywhere in this reader.
    - STATUS ICON COLOURS DECODED AND PROVEN, not guessed: each card shows three flags, Test Driven, Bodywork, Mechanical / Electrical, each a green tick or red cross icon, with no title or aria-label text anywhere to say which means what. Confirmed the real mapping (green = clear, red = has an issue) two independent ways against the same real car (BU19DDL): its own detail page reads "No Bodywork Issues Reported" for a green Bodywork icon, and a genuine disclosed fault ("has a little whistle... boosts well no lights on dash no codes") for a red Mechanical / Electrical icon. A third colour (blue, or a pale grey) also appears and is treated as unknown, never guessed as either state (golden rule 3).
    - NO NUMERIC RESERVE ANYWHERE, checked properly rather than assumed: the raw HTML (list and detail) was searched for a hidden script value the way Auction4Cars turned out to have one, none found here, only "Reserve Met" / "Reserve Not Met" status text (amber or green). Current Bid (once one exists) or Starting Price (before any bid) stands in for reserve, the platform's own real figure, same reasoning as Auction4Cars and Dealer Auction's own stand ins, labelled "Bid price" on the card's own gauge (not "Reserve") so it is never presented as the real threshold, which the "Reserve Not Met" wording on the same listing openly admits could be higher.
    - NO CONDITION GRADE AND NO ALLOWED LOCATIONS LIST NEEDED: like Auction4Cars, `assume_grade_ok=True` for this source (`daily_run._grade_ok_for`), condition judged from the three flags and the photos instead. Unlike Auction4Cars or Dealer Auction, DealerWay gives a real exact distance directly on every card ("209 Miles Away"), so it uses the normal mile limit gate (`max_distance_miles`) like Motorway and Carwow, not a location allow list, the first of the harder-to-place platforms to actually carry real distance.
    - PER LOT COUNTDOWN, no real timestamp anywhere either, only a live ticking "DD:HH:MM:SS" string, so `auction_ends_at` is computed as now() plus that duration at read time (`_end_time`), an approximation accurate to the few seconds a read takes, the same shape Auction4Cars' own countdown already feeds the cockpit's per second ticking badge, Ends within filter and Ending soonest sort, all of which picked this new source up automatically since none of them are hardcoded to a fixed platform list.
    - COMBINED ISSUE FIELDS, a genuine new judgement call: DealerWay discloses "Electrical or Mechanical Issues" as ONE combined flag (no way to tell electrical from mechanical apart), mapped to `car.mech_issue`, a hard reject, matching the existing "any known mechanical issue rejects regardless" rule played safe rather than invented as something softer. "Bodywork Issues" has no established hard gate rule anywhere else in this project, so it stays a flag (`car.notes`), never a rejection, until Mark says otherwise. A detail page's separate free text "Other" disclosure also becomes a flag.
    - Owners (the hard gate needs a real count, never on the list card), service history and the real disclosed issue text all need a detail page visit, the same shape as Carwow and Auction4Cars: gate passing candidates only, via `enrich_from_detail`, its own parsing logic factored into a pure `_detail_fields(text)` (unit tested directly against a synthetic fixture, no browser needed) the same separation auction4cars.py's own `_detail_fields` already established.
    - Wired through the whole stack: `bidbrain/browser.py` (SITES entry), `bidbrain/pricing.py` (DEFAULT_SETTINGS.platforms, _PLATFORM_SETTINGS_KEYS), `daily_run.py` (PLATFORM_KEYS, PLATFORM_SOURCE, `_read_dealerway` mirroring `_read_carwow`'s None-on-failure fallback shape, `_grade_ok_for`, site health recording, `_reserve_drop_flag`'s own "Bid price dropped" wording), `bidbrain/render.py` (`_platform_class`, the Integrations settings tab, the cockpit's platform tab and Run modal checkbox, `_categorize_notices`' platform keyword list, the reserve gauge's "Bid price" label, the settings page's own hard gate panel hiding the grade checkboxes the same way Auction4Cars' tab already does), `serve.py` (`_PLATFORM_LABELS`, `_RELOGIN_SITES`, so DealerWay gets a real relogin card on the Integrations tab for free, the same generic mechanism built earlier this session). No captured logo yet (the site's own wordmark is an inline SVG component, harder to lift live than the others' plain image assets), every logo dependent spot already had a graceful plain text fallback for an unrecognised platform, used here deliberately rather than invented.
    - A REAL, SEPARATE BUG FOUND AND FIXED WHILE VERIFYING THIS LIVE, not specific to DealerWay but only ever surfaced by adding a fifth platform: the settings page went blank on its new DealerWay tab and showed Motorway's own real customised values (104000 max mileage, not the real 90000 default) instead. Root cause: GET /api/settings did a plain top level `{**DEFAULT_SETTINGS, **db.get_settings()}`, and the real database already held a genuine saved "platforms" row from Mark's own earlier real edits (motorway max_mileage 104000, min_age 4, a genuine customisation, not a test artifact), saved before DealerWay existed; that saved value replaced `DEFAULT_SETTINGS["platforms"]` WHOLESALE rather than merging into it, silently dropping the new platform's own defaults entirely, and `_validate_settings` (which requires every known platform key to be present) would have made SAVING from any tab fail outright the moment it was tried. This is the exact same class of bug the "integrations" key was already protected against (see the Integrations tab entry above), just never applied to "platforms" itself, since no platform had ever been added since that settings feature was built. Fixed with a new shared `_merged_settings(path=None)` doing the same per key merge for "platforms" that "integrations" already had; the optional path argument exists only for tests, since db.py's own functions default their own path argument at definition time, so monkeypatching db.DB_PATH after import silently does nothing (caught by a first version of the test that looked like it passed but was actually reading the real production database the whole time, caught by checking what a deliberately different value in an isolated temp db should have produced and finding the test did not detect it). Verified live end to end: DealerWay's own tab correctly shows real defaults (90000 mileage, no grade checkboxes), saving from it does not touch Motorway's own 104000, and Motorway's own tab still shows 104000 afterward too.
    - VERIFIED LIVE, PIPELINE PROVEN END TO END on real data, though the current real inventory happens to have nothing that clears the buying brief: `daily_run._read_dealerway` (called directly with today's real date, a genuine partial run's own stale cached sale_date reuse was ruled out as the reason an earlier attempt read 0 cars, see below) read 6 real live auction cars ending today or tomorrow correctly out of 8 total on the site (the other 2 correctly excluded, ending 3 days out, past the reading window), each with correct reg, make, model, year, mileage, transmission, fuel, exact distance and reserve stand in. All 6 correctly REJECTED for real, legitimate reasons (99,945 to 107,000 miles against the 90,000 limit, 11 to 15 years old against the 2 to 10 window, up to 299 miles against the 210 limit), proving the gate integration is genuinely live and correct, not just unit tested. Fed through `_assess` and `render.render_page` with no error. A first attempt via `python3 daily_run.py --platforms dealerway` misleadingly read 0 cars; investigated rather than assumed broken, found `run()`'s own partial run logic reuses the LAST CACHED run's sale_date when none is passed explicitly (`data/last_run.json` still held 2026-08-21, three days stale), so the multi day window filter correctly excluded every real lot against that old date, a pre existing, correct-by-design interaction with a stale cache, not a DealerWay specific bug, ruled out by calling `_read_dealerway` directly with today's own real date instead.
    - Owners could not be proven live this session (no car reached the detail enrichment step today, none passed the list gate to trigger it), but `enrich_from_detail`/`_detail_fields` were verified directly and separately against the real BU19DDL detail page (owners 3, service history "none", the real disclosed mechanical fault text, the real "Other" disclosure text, all exactly matching the page), and again against a synthetic fixture in the test suite. Pagination beyond one page is also unverified live, only one page has ever existed to test against (8 cars, the site's own next arrow correctly `aria-disabled="true"`), built defensively against that real disabled state signal but not proven to actually click through more than one page.
    - 35 new tests (parse_listing/parse_card against a synthetic list fixture matching the real card shape, including a Buy Now only card correctly producing nothing; _end_time's own duration arithmetic and its rollover; _split_make_model; _detail_fields against a synthetic detail fixture; the settings platforms merge fix, proven against a real isolated database, not the live one). 344 tests pass. Purchases (only Motorway and Carwow feed that table today) and a captured logo are both left for later, the same "not yet built" status Auction4Cars and Dealer Auction started at too.
  - A GENUINE PROGRESS BLIND SPOT DURING DETAIL ENRICHMENT, FOUND AND FIXED, 2026-08-24, same day, from two real live incidents (Mark: "when i do a full 'Run' the carwow shows stuck on page 17, no update for 225s", then separately "now Carwow is empty"). Investigated both properly rather than assumed the pagination itself was frozen: `db.get_site_health()` (the site health record built earlier the same day, see above) showed Carwow's own list read (`read_all_pages`) had genuinely already SUCCEEDED cleanly both times, so the real hang was happening later, inside the per candidate `enrich_from_detail` loop for owners and VAT, which had NO progress reporting at all, so a genuinely slow but working multi minute enrichment pass looked on the cockpit's own progress bar identical to a truly frozen browser, the exact ambiguity the 90 second staleness warning exists to catch but could not here since nothing was ever written during that phase to go stale against. Fixed by adding a `_progress("reading", f"Carwow detail {i + 1} of {len(candidates)}: ...")` call inside all three per candidate detail loops that had this same gap, Carwow, Auction4Cars and DealerWay (Motorway's own detail pass already had one). This fixes the VISIBILITY gap, not necessarily the underlying cause of either incident, a genuinely unresponsive headless Chromium process (0% CPU, past every configured Playwright timeout) is a known recurring category in this project (see the browser leak and session death entries earlier in this file) that no amount of progress reporting alone can recover from, only make obvious sooner so it gets Stopped and retried rather than mistaken for something else. Both live incidents were resolved the same way already established for this class of problem, `POST /api/stop-run` (confirmed via `ps` to leave no orphaned Chromium processes either time) followed by a fresh run.
    - "Carwow is empty" traced to the interaction of two already known things, not a new bug: the first hung run above genuinely never reached its own final save step (this was still the old, single write at the very end architecture, see the incremental write entry directly below), and a partial platform read failing outright has been documented since 2026-08-21 as wiping that platform from the shortlist if there is nothing good to fall back on, both explained honestly to Mark rather than reinvestigated as something new. Resolved by triggering a genuine fresh full run once the hung one was stopped.
    - ONE IMAGE PER CAR, CUT BACK FROM THE FULL GALLERY, same day (Mark: "for now just pull one image of each car see if this can complete the scrape faster", offered as one of several mitigations while the two hangs above were being investigated). The full listing photo gallery captured per car since 2026-08-21 (`carwow.py`, `auction4cars.py`, and `motorway.gate_details`, all three already parsing an already loaded page, no extra network request of their own) is trimmed to `[:1]`, the first photo only, in all three readers. `motorway.read_purchase_gallery` (a wholly separate, purchases page only function) was deliberately left untouched, out of scope. This is a cheap, parse only change (slicing an already built list), so it was implemented as a safe experiment per Mark's own request rather than expected on its own to fix a genuine browser level hang, the real fix for that is the progress visibility one above (so a hang, if it recurs, is caught and retried quickly) plus the per platform incremental writing below (so a hang on one platform no longer holds back every other platform's own already finished results).
  - EACH AUCTION HOUSE TREATED INDIVIDUALLY, PLATFORM BY PLATFORM RATHER THAN ALL AT ONCE, 2026-08-24, same day (Mark: "the auctions should be treated individually so that once mot[orway] scrape is done it values them and so the latest cars are viewable in each auction faster rather than having to wait until the whole 'Run' is complete for all auctions"). `daily_run.run()` previously read every selected platform first, merged the whole pool, gated and valued it all together (sorted cheapest reserve globally), and only ever wrote the cockpit once, right at the very end, so a slow or genuinely hung platform (exactly the Carwow incidents just above) held back every other platform's own already finished results too, even ones that had read and valued cleanly minutes earlier.
    - Restructured into a `for key in PLATFORM_KEYS:` loop (skipping any key not in the selected set, same partial run mechanism as before, untouched): each platform is read, gated, assessed, valued and re assessed with pricing in on its own, then immediately folded into a running shortlist/held/rejected total and written to the cockpit via a new `_write_run_so_far(shortlist, held, rejected, today, sale_date)` (saves the DB assessments, saves `last_run.json`, rewrites `cockpit.html`/`hidden.html`, exactly what the old single end of run write already did, just callable repeatedly). So Motorway's own results are live on the cockpit the moment Motorway is done, without waiting on Carwow, Auction4Cars, Dealer Auction or DealerWay to finish.
    - Motorway stays its own special case within the loop, unchanged in behaviour: its reader still fails loud (raises rather than returning None) and its own detail pass still carries the same fail loud guard (`golden rule 5`, stops the whole run if under half the candidates returned a keeper date), now checked as soon as Motorway's own iteration reaches it rather than only after every platform had already been read. The other four platforms keep their existing None on failure fallback (a dead login falls back to that platform's own cars from the last good run), resolved per platform inside the loop rather than in one batch afterward, and still writes the cockpit immediately after a fallback too, not just after a successful read.
    - The Glass's and Cazana circuit breaker (`_try_glass`/`_try_cazana`, dead after 4 consecutive failures) and the 48 hour valuation cache (`_cached_or_live`) are defined ONCE, outside the loop, so their state is genuinely shared across platforms exactly as it always was across cars, a source that dies on Motorway's own cars stays dead for Carwow's, not reset per platform. The "priced 0 of N cars, both sources look down" fail loud guard also moved inside the loop, now checked per platform as soon as it is known rather than only after every platform had been valued, so a dead valuation source is caught and the run stops loudly the moment it first bites rather than after needlessly reading four more platforms first.
    - `test_limit` (the dev only `--test N` flag) is now applied PER PLATFORM rather than once across the combined pool (the N cheapest of EACH platform read, not the N cheapest overall), documented in `run()`'s own docstring as an accepted side effect of valuing platform by platform, not a regression to fix.
    - No dedicated unit test (`run()` has never had one, it is network dependent, verified live only, matching this project's own established pattern for every platform reader). 344 tests pass (no regression, nothing this touches was previously covered).  
    - VERIFIED LIVE the same day, on a real full run. Motorway finished first (78 shortlisted, 155 held, 340 rejected, mostly 48 hour valuation cache hits so it moved fast) and `last_run.json`/`cockpit.html` were rewritten with exactly that before Carwow's own read had even started, confirmed directly against the cache file's own contents mid run rather than assumed from the log. It then genuinely proved its own resilience benefit, not just its speed benefit: Cazana's login expired mid Carwow valuing ("Cazana did not show the search box"), tripped the circuit breaker after 4 consecutive failures, and the run correctly stopped loudly (golden rule 5, "Priced 0 of 107 Carwow cars, both valuation sources look down") rather than ship an empty Carwow section, exactly as before. But because Motorway had already been written before Carwow's own failure, Motorway's 78 shortlisted cars stayed live on the cockpit untouched by the later failure, something the OLD single write at the end code could never have done, that same Cazana failure would have wiped the whole run's output, Motorway included, since nothing was ever written until the very end.
  - CAZANA ADDED TO THE SETTINGS PAGE RELOGIN MECHANISM, 2026-08-24, same day, directly off the incident above (Mark, once Cazana's own session had been fixed by hand: "we also need this to be an option in setting like glass's to login"). Cazana had always been re logged in from the terminal (`python3 login.py cazana`), it was the one real valuation source never folded into the relogin work built earlier the same day (dealerkit, glass, lecapital, then the four auction platforms). Added to `render.py`'s `_PLATFORM_LOGINS` (renders under the renamed "Auction house & valuation logins" card, since it is not itself an auction house, using the exact same login block, no code duplication) and `serve.py`'s `_RELOGIN_SITES`; no on/off toggle needed, matching every other entry on that card, Cazana has none of its own either, every run always tries it. Verified live: opened a real `login.py cazana manual` window through the new settings page button, Mark logged in, `data/cazana_state.json` was freshly written (confirmed by its own mtime), and the very next run priced Carwow, Auction4Cars and Dealer Auction on it successfully. 344 tests pass (render only, no gate or pricing logic touched).
  - A DETAIL PAGE CACHE FOR CARWOW, AUCTION4CARS AND DEALERWAY, 2026-08-24, same day (Mark, watching the restructured run above still take several minutes per platform: "as DA, A4C & DW are auctions over multiple days, are these cars info, images and values being added to a database to check for more rather than a full scrape"). Correct diagnosis: only the VALUATION (Glass's/Cazana, the 48 hour `valuation_cache` built 2026-08-19) was ever cached across days. The list read and, for every gate passing candidate, a full detail page visit (owners, service history, disclosed issues, photos, and on Carwow/Auction4Cars the reserve itself) happened fresh on every single run, even for a car unchanged since yesterday, exactly the slow part Mark had just watched (Auction4Cars alone did 237 detail visits in one run). Asked Mark to choose the caching scope rather than guess (a static fields only cache with reserve always live, vs also caching reserve itself with a short TTL, vs leaving it for later); he chose to cache reserve too, short TTL.
    - New `bidbrain/db.py` table `listing_detail_cache` (cache_key, platform, detail_json, fetched_at, UNIQUE(cache_key, platform)), `DETAIL_CACHE_TTL_HOURS = 4` (Mark's own "a few hours"), `get_cached_detail`/`save_cached_detail`/`prune_old_listing_detail_cache`, the same shape as the existing valuation cache functions. detail_json is not a fixed field list, it is whatever fields a real detail read actually changed, a genuine before/after diff, so a reader gaining a new field some day needs no update here.
    - New `daily_run._enrich_with_cache(platform, c, enrich_fn, db_path=None)`, wrapping all three platforms' own per candidate detail loops (Carwow, Auction4Cars, DealerWay; Dealer Auction has no detail visit at all to cache, everything including its own reserve stand in is already on the list card). On a hit, applies the cached fields straight onto the fresh car object and skips the real page visit entirely; on a miss, runs the real read, diffs the car's own fields before and after (via `asdict`, excluding private `_` prefixed bookkeeping fields), and saves whatever changed. `db_path` defaults to `db.DB_PATH` resolved fresh at CALL time, not baked in as a default at import time, the same lesson already learned building the settings platforms merge fix earlier the same day (db.py's own functions default their path argument at definition time, so a test wanting an isolated database has to pass one through explicitly at every level, never rely on monkeypatching `db.DB_PATH`).
    - Auction4Cars needed its own key: its own registration is only ever read FROM its detail page (never on the list card), so a candidate cannot be looked up by reg before that first successful read. `_enrich_with_cache` keys on `db._norm_reg(c.reg) if c.reg else c.listing_url`, which for Auction4Cars naturally falls through to the stable per lot `listing_url` (the same real auction lot's page every day it is up), and once the real reg lands among the cached fields on the first hit, later cache hits restore it too even though the lookup itself never used it. Carwow and DealerWay both already carry a real reg straight off their own list card, so they key on that as normal.
    - Pruning wired in alongside the existing valuation cache prune, near the top of `run()` (`db.prune_old_listing_detail_cache()`, a much longer 14 day cutoff than the 4 hour reuse window, pure housekeeping so the table does not grow forever, the short TTL is what actually governs reuse). Each of the three enrich loops now prints a one line summary of how many detail reads were reused from cache versus actually made.
    - 16 new tests: a full db round trip for the new table (empty is a miss, a saved value is a hit, a different platform for the same key is a miss, Auction4Cars keying on a listing url rather than a reg, an aged out row correctly misses and is the one pruning removes), plus a dedicated `_enrich_with_cache` section proving the real mechanics against a fake enrich function and an isolated temp database (a first real call happens and is cached, a second car with the same reg hits the cache with zero real calls, Auction4Cars' listing url keying works and the discovered reg comes back on a later hit, and a car with neither a reg nor a listing url is correctly never cached at all, nothing to key on). Caught two real test bugs before trusting the result: the first version asserted the fake call log came back upper cased, but `_enrich_with_cache` only normalises the reg for its own cache KEY, it never rewrites `car.reg` itself, so the real call log correctly stays exactly as the fake reader was called with; and the very first version monkeypatched `db.DB_PATH` after import expecting `_enrich_with_cache`'s own default to follow it, which would have silently exercised the real production database instead of the isolated one, caught before it was ever run by remembering the exact same class of bug from the settings platforms merge fix earlier the same session, fixed by adding the explicit `db_path` parameter rather than repeating the mistake. 362 tests pass, none of them ever touching the real `data/bidbrain.db` (confirmed directly afterward: the new table does not even exist there yet, it is created lazily by `db.init_db()` on the next real run).
  - MOTORWAY'S OWN CSV ALREADY DISCLOSES SELLER MECHANICAL ISSUES, USED TO SKIP A DETAIL PAGE VISIT ENTIRELY, 2026-08-24, same day, right after the cache above (Mark had grabbed `Motorway vehicles 24-08-2026 15_17.csv` by hand off the live auction page just before the 3:30pm close, asking whether using it to pre filter Motorway's own candidates first might speed things up). Inspected the real file (2282 real rows, the full unfiltered national sale, much bigger than Mark's own saved search) rather than guessed at its shape: two columns neither `motorway.read_export` nor anything else in this project had ever read, "Mechanical issues disclosed by seller" (a clean Yes/No, 98 of 2282 real rows Yes) and "Additional mechanical information" (the seller's own free text, for example real disclosed rows reading "Brake pads" or "PSR - Doors & boot central locking not working"), sitting right alongside every column `read_export` already parses. Crucially, this is not a new file to grab by hand each day, it is the SAME "Download > Filtered vehicles" export `read_export` already downloads automatically every run, just two more columns of it being read.
    - No new manual workflow needed, a pure code change. `motorway.read_export`'s CSV row loop (pulled out into a new pure `parse_export_rows(rows)`, testable against synthetic rows with no browser or real download, the same separation every other reader in this project already keeps) now sets `car.mech_issue` straight from the CSV when "Mechanical issues disclosed by seller" reads Yes, using the seller's own free text where given, falling back to "declared by seller" otherwise, matching the exact fallback wording `gate_details`'s own detail page read already uses for the identical case.
    - The real speed win: `daily_run.py`'s Motorway candidate filter (`mw_candidates = [c for c in cars if _list_gate_ok(c, today) and not c.mech_issue]`) now excludes a CSV flagged car from the whole detail page pass entirely, since `assess()` hard rejects on `car.mech_issue` regardless of what the detail page might additionally reveal, so visiting its page could never change the outcome. Deliberately never the other way round: a car the CSV does NOT flag still gets its full detail page visit as before (`gate_details` can catch a real issue the CSV missed, and still independently confirms keeper start date, warning lights and the selling plate, none of which are in this CSV at all), so this can only ever SKIP a page visit for a car that was always going to be rejected, never admit one that would otherwise have been excluded, playing safe exactly as golden rule 3 and 4 require.
    - Honest sizing, not oversold: on the real 2282 row national sample about 4.3% of cars carry a disclosed mechanical issue, so against a real filtered candidate list (467 on a recent live run) this trims perhaps 15 to 20 detail page visits, a modest but genuinely free win, not a transformation of the 400 plus visits still needed for the keeper date, warning lights and private plate checks this CSV cannot help with. Said so plainly to Mark rather than oversell it.
    - 7 new tests against synthetic CSV rows matching the real column names (a clean row carries no mech_issue, a disclosed issue with real text uses the seller's own words, one with no free text falls back to the plain flag, a Buy it now row is still dropped as before, the flagged car correctly never becomes a detail page candidate while a clean car still does, checked with `gate_failures(..., settings=None)` directly rather than `daily_run._list_gate_ok`, which reads real saved settings through `_get_settings()` and so is never exercised against a fixture in this file). 369 tests pass. The manually grabbed CSV itself was not committed or used as a data source, only inspected to discover the columns, real captured business data never belongs in the repository, matching the standing rule already followed for every other fixture in this project.
  - THE DAILY SCHEDULE, BUILT PROPERLY FOR THE FIRST TIME, 2026-08-24, same day (Mark: "the automated daily run for mw new stock should be done at 4:40 and carwow (CW) at 5:10"). Only `com.bidbrain.serve` had ever actually been set up on Mark's own Mac (confirmed by checking `~/Library/LaunchAgents` directly rather than trusting the older handover note at the top of this file, which was itself honest that this step was still outstanding, "the daily and monthly run launchd schedule" listed under Still to do), so there was no existing daily job to edit, this was a genuine first build, not a retune.
    - Split into TWO scheduled jobs rather than one, matching the two platforms' own real stock timing (Motorway shows tomorrow's stock after 4:30pm, Carwow only after 5:00pm, see "When the read can run" earlier in this file) and taking direct advantage of the per platform partial run mechanism built earlier the same day: `com.bidbrain.daily-motorway` (`~/Library/LaunchAgents/com.bidbrain.daily-motorway.plist`) fires at 16:40 running `python3 daily_run.py --platforms motorway`; `com.bidbrain.daily` (`com.bidbrain.daily.plist`) fires at 17:10 running `python3 daily_run.py --platforms carwow,auction4cars,dealerauction,dealerway`, deliberately EXCLUDING motorway since it will already have been freshly read half an hour earlier and a partial run carries its results forward untouched rather than repeating a needless second Motorway CSV download and detail page pass for data that has not changed in 30 minutes. Auction4Cars, Dealer Auction and DealerWay have no fixed daily close time of their own (multi day auctions, self filtered by each lot's own end time), so they were bundled into the 17:10 job rather than given their own schedule, nothing about their own timing needs a separate slot.
    - Both plists follow the exact same conventions already established for `com.bidbrain.serve.plist` (the absolute `/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14` interpreter path, since launchd does not use the shell PATH; `WorkingDirectory` set to the project root so relative paths inside daily_run.py resolve correctly), but WITHOUT `KeepAlive` or `RunAtLoad` (those are right for serve.py, a process meant to stay up forever, wrong for a one shot daily job that should only ever fire at its own `StartCalendarInterval` and then exit). Each gets its own log (`data/logs/daily-motorway.log`, `data/logs/daily.log`), separate from `manual_run.log` (which stays specific to a cockpit triggered Run) and each other, so a schedule specific problem is easy to find without wading through the other job's output.
    - Verified: both plists pass `plutil -lint`, loaded cleanly with `launchctl load -w`, and `launchctl print gui/<uid>/<label>` confirms the real registered `calendarinterval` on each (Hour 16 Minute 40, and Hour 17 Minute 10) and the exact `--platforms` arguments, checked directly against launchd's own state rather than assumed from the plist file alone. Not yet proven by an actual real firing, the very next 16:40 and 17:10 will be the first real test of the schedule end to end; worth checking `data/logs/daily-motorway.log` and `data/logs/daily.log` the day after this is committed.
    - A pre existing caveat, already on record for the old single scheduled run (2026-06-09, "avoid pressing Run now right at 17:10"), now applies to both slots: serve.py's own manual run guard (`_any_proc_busy()`) has no visibility into a launchd fired `daily_run.py` process at all, since it runs as its own independent process outside serve.py's subprocess tracking, so a manual Run now (or the on demand Glass's, DealerKit, LE Capital or relogin buttons) pressed right at 16:40 or 17:10 could genuinely overlap with the scheduled job. Not fixed here, same as it was never fixed for the original single slot, just now true of two slots instead of one, worth remembering rather than building a cross process lock for a fairly rare coincidence.  
    - The 16:40 Motorway job's first real firing started exactly on schedule (confirmed via `ps`, the correct `daily_run.py --platforms motorway` process, PID matching launchd's own `pid =` in `launchctl print`), progressed cleanly and steadily through its own Motorway detail page pass with no stalling, and FINISHED cleanly (`last exit code = 0`, confirmed via `launchctl print`), writing a real, larger Motorway shortlist to the cockpit (78 to 201 shortlisted, a genuinely fresh read, not a stale repeat). Its own log file (`data/logs/daily-motorway.log`) stayed empty for a while despite that real progress, a cosmetic point only: Python buffers stdout when writing to a redirected file rather than a real terminal, the same as it always has for `manual_run.log`, the buffer just had not flushed yet, not a sign of anything actually wrong, confirmed once the process exited and the buffer finally flushed, a real priced shortlist print out was sitting there the whole time. The 17:10 slot's own first firing was left to run unwatched on its own schedule, worth a quick check the day after.
  - A ONE CLICK "UPDATE NOW" BUTTON ON THE SETTINGS PAGE, 2026-08-24, same day (Mark: "is there a way inside the settings to have a button to trigger move to latest version"). Asked which of two scopes he wanted rather than guess, check only versus a real one click update with an automatic restart; he chose the fuller one.
    - `serve.py` gained `_current_version()` (the real tag HEAD sits exactly on, per SETUP_FOR_NEW_USER.md's own "checkout a tag, not main" convention; falls back to "branch (short sha)" when it is not, the honest state for a working session like this one), `_latest_version()` (the newest real GitHub Release via `gh release list`, not a bare `git tag -l`, since a tag can exist locally or even be pushed without ever becoming a real release, matching the REAL VERSIONED RELEASES convention already in this file; `_gh_path()` reused the same PATH-then-`~/.local/bin/gh` fallback already needed to set gh up in the first place on 2026-08-21), a new `GET /api/version`, and a new `POST /api/update`.
    - `/api/update` is deliberately careful about when it will act: refuses outright while `_any_proc_busy()` (the same shared browser profile and now shared working tree guard as every other headed pass), refuses if the working tree is not clean (`git status --porcelain`, should never fire on a real install since `dealer_config.py` and every saved session file are gitignored, but a checkout over real local changes is not something a button should ever silently discard), and does nothing at all if already on the latest release. Otherwise it runs `git fetch --tags` then `git checkout <latest tag>` (both loudly reported back to the page on failure, never swallowed), then schedules its own restart a moment later (`threading.Timer(1.5, lambda: os._exit(0))`, giving the HTTP response time to actually reach the browser first), letting `com.bidbrain.serve`'s own `KeepAlive` bring the process straight back up reading the new code fresh off disk, the exact same effect as the manual `launchctl kickstart -k` used by hand after every code change this whole session.
    - `render_settings()` gained a "Software update" card, the first thing on the Integrations tab, showing the current and latest version and an "Update now" button (hidden unless a newer release actually exists), with its own confirm dialog warning about the restart. After confirming, the page polls `/api/version` every two seconds until the server responds again (the brief window while it restarts) and reloads automatically.
    - Verified live end to end, as much as safely could be without an actual newer release to update to (v2.5 having only just been cut the same session, this Mac is already on it): restarted the real server, `GET /api/version` correctly returned `current: "v2.5", latest: "v2.5", update_available: false`, and `POST /api/update` correctly returned `already_latest: true` without touching git or restarting anything, confirmed by checking the server was still answering immediately afterward. The checkout failure path was checked directly against git itself (`git checkout` on a made up ref exits non zero with a clear stderr message, exactly what `_update` catches and reports), and the dirty tree guard was confirmed live against the real working tree (this very feature's own uncommitted files were correctly detected by `git status --porcelain` moments before being committed). The restart mechanism itself (`os._exit(0)` under a KeepAlive launchd job) was not re proven in isolation, it is the identical effect already used successfully many times over this same session via the manual `launchctl kickstart -k`, just triggered from inside the process instead of from a terminal. The next real cross version test will be whichever dealer next has an actual older install and a newer release to update to, worth a real live check then rather than assumed safe forever from today's same version check alone.
  - A COCKPIT PRICE CEILING, DISPLAY ONLY NEVER A GATE, 2026-08-24, same day (Mark: "i wouldnt bid any more than £11500 for a car so there no point seeing these cars in the live view but that might change in the future so need this editable", extended minutes later: "make sure the Reserve price from the CSV or scrape doesnt go above £11500 as well"). Asked which of two shapes he wanted rather than guess, a real hard buying rule (rejects the car outright, needs a re run whenever the ceiling changes) versus a pure display default (hides it from the cockpit's live view, everything else about the car untouched, takes effect instantly on reload); he chose the display default, matching his own "might change" framing, a hard rule would mean a full re run every time he adjusts it. One figure covers both Max bid and Reserve, per his own "as well" wording, not two separate settings.
    - `pricing.DEFAULT_SETTINGS` gained `default_price_ceiling` (nullable, None is the same "no ceiling" behaviour the cockpit always had before this existed), living alongside `dealer_postcode` as a second REFERENCE ONLY setting, deliberately never read by `gate_failures`, `class_exclusion`, `assess` or `price_car`, so it can never accidentally start rejecting cars. `serve._validate_settings` treats an empty string, a missing key, zero or null all the same way, "not configured", never guessing a figure to stand in for unset; a real number still goes through the same `_num` positive check every other money field on the page already uses.
    - The mechanism is deliberately not a new, separate filter: it just PRE FILLS the cockpit's own existing sidebar "Max bid: Up to" AND "Reserve: Up to" boxes on page load (`render.render_page` gained a `default_price_ceiling` parameter, baked into the page as `const DEFAULT_PRICE_CEILING = <value or null>;` right before the very first `applyView()` call, setting both `filterbidmax` and `filterreservemax` from the one figure), so the exact same filter pipeline, count recompute and Reset behaviour that already existed for a manually typed ceiling now also applies to this one, no new code path to keep in sync with the real ones. Clearing either box, or clicking Reset, shows everything again for that session; nothing about pricing, gating, held or rejected status, or any count anywhere on the page is touched by it, exactly Mark's own "no point seeing these cars" framing rather than a buying rule.
    - A new "Cockpit display" card on the settings page's Overview tab, right after Dealer location, its own note spelling out plainly that it is display only, covers both figures, and is reversible per session, so it does not read like another buying rule sitting among the real ones.
    - Verified live end to end against the real running server and a real browser tab, not just the unit tests: saved a real ceiling of 11500 through the actual `/api/settings` POST, rebuilt the real cockpit, confirmed via the page's own DOM that both `filterbidmax` and `filterreservemax` were genuinely pre filled with 11500 and that of 350 real cards, exactly 289 were visible with zero of them over the ceiling on EITHER figure (61 correctly hidden); called the page's own `resetFilters()` and confirmed both boxes cleared and more cards reappeared (334, not all 350, other filters and hidden cars still apply as normal). Reset the real saved setting back to unset afterward, £11,500 was Mark's own example figure, not a value he asked to actually keep live. 8 new tests (`_validate_settings`' handling of a real number, an empty string, a missing key and a negative number; `render_page` correctly baking the same figure into both `filterbidmax` and `filterreservemax`, versus `null` when none is given). 377 tests pass.
    - Released as v2.6.1 (Mark's own call: "make these small changes a v2.6.1 release so users can stick to the main v2.6 then v2.7 stable releases"), a small follow up patch on top of v2.6 rather than folded into whatever v2.7 ends up being, the same pattern already used for v2.4.1 and v2.4.2 on top of v2.4.
  - DEALERWAY'S OWN WINDOW WAS FAR TOO NARROW, A REAL BUG FOUND AND FIXED, 2026-08-24, same day (Mark: "theres something wrong with the dealerway auction im showing no available cars however when i search the auction site 210 miles from po169dr i have 2 live auctions and 45 buy it now"). Investigated live rather than guessed: a direct read against the real authenticated DealerWay session found 7 genuine LIVE AUCTION cards on the site right now, but `dealerway.parse_listing` was still on the original narrow "ending today or tomorrow" window from when the platform was first built 2026-08-24 earlier the same day, never widened the way Auction4Cars' own identical window was fixed on 2026-08-21 once its own lots turned out to run several days too. 3 of the 7 real cards (ending 2026-08-27, 3 days out) were being silently dropped before ever reaching the gate, one of them (DN17WRR, 28 miles away) squarely inside Mark's own 210 mile search. Reproduced his exact numbers: of the 7 real cards, exactly 2 (YR55RMX at 142 miles, DN17WRR at 28 miles) sit within 210 miles, matching his own "2 live auctions" count exactly; the other 45 he saw are Buy Now listings, correctly out of scope everywhere in this project (auction only, golden rule).
    - Fixed the same way Auction4Cars was: `parse_listing`, `read_all_pages` and `read_live` all gained a `days_ahead=7` parameter (matching Auction4Cars' own default, "the longest a real auction runs"), and the date check changed from a fixed 2 element membership test to a proper inclusive range (`today <= end_date <= window_end`), the exact same correction Auction4Cars needed for the exact same reason, that trick only ever worked while there were exactly two valid dates. `daily_run._read_dealerway`'s own call site needed no change, it already just calls `read_all_pages(page, today=today)` and picks up the new wider default automatically.
    - Neither of the two cars actually within range make the shortlist even with the fix, for real, honest reasons rather than a further bug: YR55RMX is 21 years old (outside the 2 to 10 year hard gate window) and also missing an owners count; DN17WRR is missing mileage, reserve, year and owners entirely, a genuinely sparse early stage listing. Both now correctly show up in Rejected with real reasons instead of silently vanishing before ever being read, which is the actual fix, not manufacturing a shortlist that should not exist. Told Mark plainly that the underlying complaint (nothing showing) is fixed, but neither of the two nearby cars will pass the buying rules as they stand today.
    - Verified live end to end, twice: a direct read against the real session confirmed all 7 cards now parse (up from 4), and a real partial run (`--platforms dealerway`) through the actual daily_run.py pipeline confirmed the same 7 cars reach the cockpit's Rejected bucket (0 shortlisted, 0 held, exactly as expected given the two real reasons above), replacing the previous run's 4. New fixture card (`data/fixtures/dealerway_list_sample.json`, a 4th synthetic LIVE AUCTION card 10 days out, never real captured data) proves the widened window still excludes something, mirroring Auction4Cars' own "one card sits exactly on the edge, another sits well past it" test shape; the existing two in window fixture cards needed their test call switched from an implicit "no date filter" (`today` never passed) to an explicit `today=` matching their embedded dates, since the old "None means skip the filter entirely" escape hatch does not exist in `parse_listing` (nor did it in Auction4Cars', it was never a real contract, just an untested assumption). 3 new tests, 378 tests pass.
