# BidBrain project status, part 11: v2.8.x to v2.9, nav and the purchases and lost bids pages (25 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.

  - THE % OF RETAIL IDEA ACTUALLY WIRED IN, shipped as v2.8.1, 2026-08-25, same day (Mark: "is the % of retail being used to based the suggested bids?" then "yes wire it in", after v2.8 shipped with the retail data collected but the actual suggestion still computed purely off similarity ranked raw winning prices, never a percentage of anything). `bid_suggest.suggest_bid_for_car` gained a `car_retail` parameter: within the SAME similarity ranked cohort already chosen for the like for like comparison (mileage/year/transmission/engine ranking is untouched, retail plays no part in ranking, only in scaling the final figure), when this car's own retail is known and at least `min_ratio_samples` (3) of that cohort carry a real retail figure of their own, the suggestion is now the 70th percentile of each comp's own (winning price / its own retail) ratio, applied to THIS car's own retail, rather than a flat percentile of raw prices; falls back to the raw price approach whenever this car's retail is not yet known (no Cazana match) or too few comps in the cohort have one. `daily_run.py` passes `car_retail=a.pricing.cazana_retail if a.pricing else None` (the car's own plain, unscaled Cazana retail figure, the exact same basis `db.bid_comps_full()`'s own `retail` field already uses for every comp, purchases via `cazana_115 / 1.15`, lost bids via `cazana_retail` directly, so both sides of the ratio are genuinely comparable). New `car._suggested_bid_ratio` flag threaded the same private, never persisted way as `_suggested_bid_llk`; `render._hf_gauge`'s tooltip now says "scaled to this car's own retail value" when it applies. Verified live: `daily_run.py --render-only` against the real 458 shortlisted cars showed 145 of the 209 suggested cars (about 69%) now using the ratio based figure, the rest correctly falling back (either no Cazana match on that specific car, or too few retail carrying comps in that model's cohort yet, both honest, expected gaps that close as the enrichment backfill and daily reads accumulate more retail data). 5 new tests (a synthetic case where every comp sold at exactly 90% of its own retail proving the suggestion lands on exactly 90% of a different car's own £15,000 retail regardless of the comps' own very different raw prices; both fallback paths, no car_retail and too few ratio-carrying comps; the gauge's own new tooltip wording). 434 tests pass.
  - CAZANA LABEL ON THE SHORTLIST CARD DEEP LINKS TO ITS OWN REAL LOOKUP, built and verified live 2026-08-25, NOT YET RELEASED (Mark: "i want to add a link on the card on the word Cazana to deep link to lookup page - https://trade.percayso-vehicle-intelligence.co.uk/companion/search/4175c7ef-8e7c-47f7-b073-455fd8a306c3, this is the YE18WUA"), waiting for the go ahead per his own "only push to V2.9.1 ... when i tell you". The uuid in that url (`4175c7ef-...`) is generated by Cazana per search, never derivable from the reg, so the only way to get a working link is to capture the real result page's own `page.url` at the moment of a live lookup, not construct one after the fact.
    - `bidbrain/readers/cazana.py`'s `lookup()` now returns `(value, url)` instead of a bare value, `page.url` captured only once the function is about to return a genuine confirmed result (never on any of its own early `None` returns, in particular never when `details_match` rejects a mismatched car via `expect`, which would otherwise hand back a real, working link pointing at the WRONG car).
    - `bidbrain/db.py`: `valuation_cache` gained a `url` column (idempotent `ALTER TABLE` for existing databases, added to the base `CREATE TABLE` for new ones), so a value served from the existing 48 hour cache (built 2026-08-19, a plain reg/mileage/source cache) still carries a real, working deep link rather than only a value with no link on a cache hit day. `get_cached_valuation` now returns `(hit, value, url)`, `save_cached_valuation` gained an optional `url=` parameter. Glass's rows simply never set it, no equivalent deep link exists there.
    - `bidbrain/pricing.py`: `Car` gained `cazana_url: str = ""`, a plain dataclass field so it rides along through the existing `asdict()`/`Car(**d["car"])` cache round trip for free, same as every other field, no extra plumbing needed; a car cached before this shipped simply has no `cazana_url` key in its saved JSON, which `Car(**...)` handles correctly by falling back to the dataclass default, proven live by a full `--render-only` rebuild of the real 458 car list completing with no error.
    - `daily_run.py`'s `_try_glass`/`_try_cazana`/`_cached_or_live`/`_value_one` (the 48 hour cache wrapper built 2026-08-19) reworked to thread `(value, url)` uniformly through both sources, `_try_glass` just always returning `(v, None)` so the shared `_cached_or_live` does not need two different shapes. `c.cazana_url` set from the live or cached lookup the same way `c.cazana_retail` already was, including on the private plate retry path.
    - Two other real call sites of `cazana.lookup` (`purchases_run.py`'s own Cazana retail estimate lookup, `bid_retail_enrich.py`'s CompMatch backfill) updated to unpack the new `(value, url)` tuple and discard the url, deliberately scoped narrowly to what was actually asked, "the card", not extended to the purchases or Didn't win pages in this pass.
    - `render.py`'s `_shortlist_card`: the Valuation detail chip's own "Cazana" label (`vals = [("Glass's", ...), ("Cazana", ...), ...]`, the one place the bare word "Cazana" appears as a per-car chip on every priced car, not just the hero label which only ever shows it when Cazana happens to be the GOVERNING source) becomes a real `<a href="{car.cazana_url}" target="_blank" rel="noopener">` when `car.cazana_url` is set, still just plain `<em>Cazana</em>` text otherwise, same fallback for every car valued before this shipped. New `.hf-val-link` CSS matches the existing `<em>` label's exact size, weight and colour so it does not stand out until hovered, then turns the brand accent green with an underline.
    - Verified: a synthetic card built with the real reg and URL from Mark's own message (YE18WUA, the exact `.../companion/search/4175c7ef-8e7c-47f7-b073-455fd8a306c3` link) rendered the real `<a>` tag with that exact href; a second synthetic card with no `cazana_url` set correctly rendered the plain `<em>` label, proving the fallback; the real live cockpit (rebuilt via `--render-only`, restarted `com.bidbrain.serve`) correctly still shows the plain label for every one of the 458 real cars, none of which have a captured url yet since this shipped after they were last valued, exactly the expected honest state. 5 new/updated test sections (the valuation cache round trip extended to the 3 tuple shape throughout, one new check proving a saved Cazana url round trips correctly using Mark's own real example url). 501 tests pass.
    - A REAL BUTTON TOO, straight after (Mark: "add this button for the deep link under the valuation detail"), not just the inline label above. `"Open Cazana lookup ↗"` (`.hf-cazana-btn`, the same feather style external arrow convention as the card's own "View on Motorway/Carwow" CTA, `content:"\2197"`), appended as the LAST child of the very same `.hf-vals-grid` that already holds the value chips, deliberately not a second grid: `toggleVals()`'s own JS only ever finds the first `.hf-vals-grid` inside `.hf-vals` (`btn.closest('.hf-vals').querySelector('.hf-vals-grid')`), a first attempt at a second, separately hidden grid for just the button would have silently never opened. `flex-basis:100%` on the button forces it onto its own full width row underneath the wrapping chip row, inside the one grid `toggleVals` already knows how to show and hide, no JS change needed. Hidden entirely, not a dead button, when `car.cazana_url` is empty, same guard as the inline label.
    - Verified live end to end, real DOM inspection rather than the screenshot tool (which reliably failed to capture the scrolled state this session, a known limitation already on record in this file, "preview_screenshot only captures at scroll zero"): temporarily patched one real car in `data/last_run.json` (HT67JXC) with the same real Cazana url, rebuilt and restarted the live server, confirmed via `getBoundingClientRect()` the button renders at 931px wide (far wider than a single chip, proving `flex-basis:100%` genuinely forced its own row) with the correct visible text and `href`, inside a visible (not hidden) grid holding exactly 3 children (2 real value chips plus the button). Restored the original `data/last_run.json` afterward, confirmed byte identical to a backup taken before the patch (matching checksums) and rebuilt the real cockpit again. Also confirmed with a synthetic no-URL card that the button is fully absent, not just hidden, when `car.cazana_url` is empty. 501 tests pass (this specific button has no dedicated unit test, render only, no gate or pricing logic touched, verified the way described above instead, matching this card's own established pattern for anything needing a real rendered DOM).
  - "DUE IN" (COLLECTION DATE) COLUMN ADDED TO THE PURCHASES PAGE, built and verified live 2026-08-25, NOT YET RELEASED (Mark: "for the purchases module can you add in a colume for due in date (collection date)"), waiting for the go ahead per his own "only push to V2.9.1 ... when i tell you". The real source already existed but was never wired into the daily capture: `motorway.read_collection_date(page, vehicle_ref)`, built 2026-08-23 for the one off Tax Date correction batch, reads the real date Motorway's own transport actually collected the car off its own delivery timeline page (`DELIVERY_TIMELINE_URL`, the "Collected" line, never the "Delivered date" on the purchase summary or the inspection report's own timestamp, both real but wrong for this), grepped and confirmed it had never been called from any real pipeline, only referenced in a docstring.
    - `purchases_run.py`'s existing Motorway payment breakdown loop (one continuous session, per car) now also calls `motorway.read_collection_date(dpage, r["vehicle_ref"])` right after the gallery read, its own extra page visit like the gallery already is (confirmed `read_purchase_breakdown`/`read_purchase_gallery` both navigate fresh via their own `page.goto` on every call, so inserting a third navigation between them is safe, the next car's own loop iteration re-navigates regardless of where this leaves the page). Wrapped in the same per car try/except as everything else in that loop, so one car's failure never stops the batch.
    - `bidbrain/db.py`: new `purchases.collection_date` column (idempotent `ALTER TABLE`, added to `_PURCHASE_OWN_FIELDS`), a real fixed fact read once and never re-read or overwritten, same convention as `mileage`. Motorway only for now, no equivalent reader exists for any other platform, so Carwow's own rows correctly stay blank rather than guessed, shown as "–" on the page, the same honest gap convention as every other platform-specific figure on this table (`protect_fee`, `cap_clean`, and so on).
    - `render.py`'s `render_purchases()` gained a new "Due in" column, positioned right after "Bought", `r.collection_date || '–'`, no popover needed, it is a plain date same as Bought.
    - Verified live end to end: `db.init_db()` migration applied cleanly against the real production database, confirmed the new column exists; the real purchases page correctly shows "Due in" as the 7th header and "–" on every current row (none of the 107 real purchases have been re captured since this shipped, so all correctly show the honest gap rather than a fabricated date); re checked the table's own width at a real 1920px viewport, `scrollWidth === clientWidth`, no new horizontal scroll introduced by the extra column. 3 new tests (no collection date yet reads back blank, a real one is stored, and it is never overwritten once set, same fill-once shape as `mileage`/`v5_photos`). 500 tests pass. The real column will start filling in on the next scheduled or manual purchases sync (15:45 daily, or the page's own Sync now button), no backfill run yet, matching the same "closes in gradually" honesty already used for `sightings.mileage`/`engine`/`transmission` when those were added.
  - PATH TO V3 MAPPED OUT, PLANNING ONLY, NO CODE, 2026-08-25 (Mark: "only lets map out the road map to the big v3, to get to v3 we need to tidy up all the things that are missing ie purchases for the other 3 auctions, lost bids etc so that everything is the working at the same for each auction house, only push to V2.9.1 as a new .1 .2 release when i tell you"). Audited the real code rather than gone from memory: `purchases_run.py`'s own `read_all()` only ever calls two readers (`motorway.read_purchases`, `carwow.read_won`), `lost_bids_run.py` only ever calls one (`motorway.read_didnt_win`), confirmed by grepping every reader module for a `read_won`/`read_purchases`/`read_didnt_win` function, Auction4Cars, Dealer Auction and DealerWay have none of the three. `dealerkit_write.push_purchase` itself takes no platform branch at all, confirmed by reading its own signature, so it should need little extra work once a platform's own purchases rows exist. Also checked and found DealerWay has no captured logo anywhere in `render.py`'s `_SECTION_LOGO`/CTA logo dicts (both stop at `auction4cars`/`dealerauction`), a small, real gap worth folding into the same pass. Written up as a new "Path to v3: bring every auction house to parity" block at the top of the README's own Roadmap section (the established single place for what's next, per the 2026-08-23 roadmap/release discipline entry above), ahead of the existing "Next up" items (shortlist watchlist sync, DealerKit monthly reports, the DealerKit financials autofill mystery), which stay but are now explicitly marked "not blocking v3". The old, now redundant "Auction4Cars and Dealer Auction purchases" bullet was folded into and superseded by the new, more complete block (also covers DealerWay and lost bids for all three, plus Carwow's own missing lost bids reader, none of which the old bullet mentioned). No code touched, no version bump, matching Mark's own explicit instruction not to release until told.
  - NAV STANDARDISED ACROSS ALL FOUR PAGES, PLUS THE SETTINGS TABS MOVED INTO THE TOP BAR, shipped as v2.9, 2026-08-25, same day, closing out a run of small nav polish asks in one go (Mark, in sequence: "as we have mostly the same options ... change the nav to match the home page, however add in overview in stead of all and add intergrations to the end, keep the same order"; then "make the shortlists cars button on all nav bar screens, and move the save setting to the top nav bar like sync and run"; then, once the settings tabs had been restyled but were still sitting in their own row below the brand, "no the nav should go on the nav bar like the other pages and use the same toggle design"; then "standardise the nav to icons for favorites, purchases, notifcations, lost bids and settings to the same order on all pages with same look and design then push v2.9").
    - Settings page's own internal tab set (Overview, Motorway, Carwow, Auction4Cars, Dealer Auction, DealerWay, Integrations, content and order both already correct, only the styling and position needed to change) restyled from the old boxy `.ptab` look to the cockpit's own pill style (`.tabs`/`.tab`/`.tab.on`), then physically moved out of `<template id="form-template">` (where it lived, cloned into `#wrap` on every load) into the STATIC `.controls` bar itself, sitting directly after the brand exactly where the cockpit's own platform tabs sit, replacing the plain `<h1>Buying rule settings</h1>` that used to be there (purchases.html and lost-bids.html have never had an h1 in their own `.controls` bar either, so removing it here made all four consistent). `.tabs`' own CSS on this page dropped `flex-wrap:wrap; margin:2px 0 14px` (meant for sitting stacked above the cards) for the same `flex:none` the cockpit and purchases/lost-bids pages already use as a flex child of `.controls`. Moving the tabs out of the template meant they are now live DOM from page load rather than only existing after the async `/api/settings` fetch resolves and clones the template in; the existing `switchView()`/deep-link (`?tab=`) JS, which already queried `#top-tabs` by id rather than by structural position, needed no changes at all.
    - Save settings and Save integrations moved out of a sticky-bottom `.savebar` (removed outright) into the top `.controls-actions` bar, as the FIRST items there, matching how Sync now (purchases/lost-bids) and Run now (cockpit) already sit as the primary action button(s) before the icon cluster on every other page. `.save`'s own CSS changed from a full width sticky-bar button to the same compact shape as `.syncbtn`/`.runbtn`.
    - A "Shortlisted cars" star icon (the same path as the cockpit's own star, `_HF_STAR_SVG`) now sits in every page's own nav bar. On the cockpit it is the existing interactive toggle (`_shortlist_navbtn`, unchanged); on purchases, Didn't win and settings (none of which have any cars of their own to filter) it is instead a link to `cockpit.html?view=shortlist`, a new deep link read on the cockpit's own page-load JS (matching the existing `settings.html?tab=` precedent) that calls `toggleShort()` programmatically once the initial `applyView()` has run, landing with the shortlist filter already active rather than needing a second click.
    - The notification bell, previously cockpit only, is now standardised onto every page. All five nav icons (favorites, purchases, notifications, lost bids, settings) now appear in that exact fixed order on every page, each page's own primary action button(s) first, a page skipping only its OWN icon (no Purchases icon on the purchases page, and so on, the same convention the three secondary pages already used for their existing icons) rather than showing a dead link; a Back to the cockpit home icon stays last on the three secondary pages, not one of the five.
      - The cockpit's own bell markup (previously built inline inside `render_page`, reading a `notices` list computed server side at render time from the real run) was pulled out into a new module level `_bell_markup(notices, cockpit=True)`, reused by `render_page` unchanged in output. The `cockpit` flag controls only ONE thing: a "Run Glass's checks"/"Run DealerKit stock" notice opens the Run modal directly via `openRunModal()` when `cockpit=True` (only the cockpit has one), or links to `cockpit.html?openrun=1` otherwise, a new deep link the cockpit's own page-load JS reads and opens the Run modal for, the same pattern as `?view=shortlist`.
      - purchases.html, lost-bids.html and settings.html have no run data of their own to bake a bell panel into at render time, so each got a static bell shell (button plus an initially empty panel) in its own nav bar, filled in by a new client side `loadNotices()` (a JS port of the same categorisation `_bell_markup` does server side: a notice starting "All " is informational, everything else counts toward the badge and gets the amber alert styling) fetching a new `GET /api/notices` endpoint. `serve._notices()` recomputes the identical picture the cockpit's own bell shows by loading the last saved run cache (`daily_run._load_cache()`, imported lazily to avoid any heavy top level import cost) and calling the same `_session_health_notices`/`_stock_health_notice`/`_platform_health_notices` functions `daily_run._write_pages` already calls, so the two never disagree; a missing or unreadable cache (never run yet) is a quiet empty list, not an error. The bell's own CSS (`.bellwrap`/`.bellbtn`/`.bellbadge`/`.bellpanel`/`.bellnote`/`.bellempty`) was duplicated verbatim (single braces, these three pages are plain non-f-string templates, unlike the cockpit's own f-string one, the same interpolation gotcha already on record elsewhere in this file) onto all three pages' own `<style>` blocks, and `toggleBell()` plus its outside-click-close listener duplicated verbatim into each page's own `<script>` block, since each page is a wholly separate static template with no shared JS file to import from.
    - Verified live end to end against the real running server, using a freshly opened browser tab each time per the stale-tab lesson already on record in this file: the settings page's own tabs now render as pills directly in the top nav bar (screenshotted, matching the cockpit's own look exactly) and switching tabs still correctly preserves unsaved typed values via the existing `syncGate()` call; Save settings sits in the top bar and still saves correctly; every one of the four pages' own star icon was clicked and correctly either toggled the shortlist filter in place (cockpit) or navigated to `cockpit.html?view=shortlist` with the filter genuinely active on arrival (confirmed via `navshort`'s own `on` class and the URL); the real bell on the purchases page showed the identical live notice text as the cockpit's own bell, and clicking its "Run Glass's checks" link correctly navigated to `cockpit.html?openrun=1` and opened the real Run modal with the Glass's button correctly flagged red. `python3 test_pricing.py` (497 tests, unchanged, this is all render/serve.py routing with no dedicated test coverage for any of these four pages beyond what already existed) and `node --check` against every regenerated page's own extracted `<script>` block both stayed clean throughout.
    - FOUR FOLLOW UP CORRECTIONS, same day, before v2.9 was ever tagged (Mark, in one quick burst after seeing the first version live): "make the bidbrain logo always link to the live auctions homepage"; "make sure the notifcations icon on the homepage matches the purchases etc"; "sorry thats incorrect, make sure the watched / shortlisted icons are always the same, but add the amount watched like the amount of notifactions" (correcting an earlier misreading of "star go yellow when watched / shortlisted cars" as a colour change, which had briefly shipped an amber `.has`/`.starhas` highlight on the star, background included, on both the cockpit's own Shortlisted button and the other three pages' star icon); "also make sure the 'run now' button is in the same place as 'save' and sync on other pages".
      - Brand: `<span class="brand">` became `<a class="brand" href="cockpit.html">` on all four pages (one `replace_all` across identical markup), `.controls .brand` gained `text-decoration:none; cursor:pointer;` on all four so it does not pick up a browser default underline now it is a real link.
      - Star reverted to a plain, always-the-same icon (no colour state at all, matching the bag/target/wrench icons exactly) with a small count badge instead, the same `.bellbadge` shape and position the bell already uses, reusing its CSS unchanged rather than inventing a second badge style: `_shortlist_navbtn`'s conditional `has` class was removed outright (back to always `class="navshort"`, the count was already shown as its own visible text there, `.fn`, so no badge needed on the cockpit's own button); the other three pages' `<a id="navstar">` gained a `<span id="starbadge" class="bellbadge"></span>` inside it (`.settingslink` gained `position:relative` to anchor it, and a new `#starbadge:empty { display:none; }` rule hides it at zero, the same convention `.bellbadge:empty` already used), and `loadNotices()`'s own `d.shortlisted` field (added to `/api/notices`, see below) now sets its `textContent` instead of toggling a colour class.
      - `serve._notices()` reworked to return `(notices, shortlisted)` rather than just `notices`, computing the real count the exact same way `daily_run._summary` does (`db.bids_for(sale_date)` against the cached run's own `shortlist`, `sale_date` now also unpacked from `_load_cache()`), so the star's new badge shows the identical real figure the cockpit's own Shortlisted button already would. `/api/notices` gained a `shortlisted` field in its JSON alongside `notices`.
      - Bell: made genuinely, not just nominally, the same on the cockpit as the other three pages. `_bell_markup` now returns `class="bellbtn hasnotice"` (border, background and icon colour all turn to `--no` red, not just the existing small corner badge count) whenever there is at least one real alert, the same `.bellbtn.hasnotice` rule already written for the other three pages when the bell was first built, just never wired up server side for the cockpit's own render path until now; `loadNotices()` on the other three pages already toggled this class, unchanged. Also caught and fixed a genuine, if minor, visual mismatch while checking this: the cockpit's own `.bellwrap` used `margin-left:14px` where every other page (and every OTHER icon on the cockpit itself) uses `8px`, now aligned.
      - Run now: found, while checking why it "wasn't in the same place" as Sync now/Save settings, that the cockpit was the one page of the four that never actually wrapped its primary action button and icon cluster in a `<div class="controls-actions">` at all, relying instead on `.runbtn`'s own `margin-left:auto` alone to push itself (and, incidentally, everything after it in the flex row) to the right, a working but structurally different mechanism to the other three pages' shared wrapper. Added the missing `.controls-actions { display:flex; align-items:center; gap:10px; margin-left:auto; }` rule and wrapped Run now, the star, and all four icons in one on the cockpit too, `.runbtn`'s own `margin-left:auto` left in place as a harmless second copy (a browser only ever spends one auto margin per row), matching the other three pages' exact markup shape now, not just a similar visual result. Checked `.controls .settingslink:first-of-type` (spacing the first icon in the cluster away from the button before it) still resolves correctly against the new, one-level-deeper nesting: `:first-of-type` counts same-tag siblings within the element's own immediate parent, and since the brand `<a>` sits in `.controls` while the icon `<a>` tags now sit inside `.controls-actions`, a different parent, the selector still correctly finds the bag icon as the first `<a>` within ITS OWN parent, unaffected by the brand also becoming an `<a>` moments earlier in the same round of fixes.
      - Verified live end to end against the real running server, a freshly opened tab each time: the cockpit's own Shortlisted button reads a plain, uncoloured `class="navshort"` (0 shortlisted, no badge needed there since the button already shows the number as text) while the bell reads `class="bellbtn hasnotice"` with a real "1" badge, both confirmed by reading the DOM directly rather than trusting a screenshot; purchases.html's own star reads plain `class="settingslink"` with an empty (hidden) badge and its bell shows the identical red/badge state as the cockpit's; the brand link's `tagName`/`href` read `A`/`cockpit.html` on every page checked; `document.querySelector('.controls-actions').children` on the cockpit lists `runbtn, navshort, settingslink (bag), bellwrap, settingslink (target), settingslink (wrench)` in that exact order, confirming the wrapper genuinely holds the whole cluster now, not just a visual coincidence. `python3 test_pricing.py` (497 tests, unchanged) and `node --check` against every regenerated page's own script block stayed clean throughout this round too.
  - VALUATIONS GIVEN THEIR OWN SETTINGS PANEL, LOGIN BUTTONS MOVED RIGHT, shipped as v2.8.19, 2026-08-25, same day (Mark: "on the settings page move glass's and cazana to their own panel, move the login buttons to the right for better space use"). Before this, Glass's sat inside the generic "Integrations" card (it has a real on/off toggle, from `_INTEGRATIONS`) while Cazana sat inside the "Auction house & valuation logins" card (no toggle, from `_PLATFORM_LOGINS`), the two real valuation sources split across two cards that were really about something else (back office automations, and auction platforms).
    - `_login_block(site)` reworked to take a `heading` parameter and build the WHOLE header row itself (`.integ-head`, a flex row: `.integ-head-text` on the left holding the `<h3>` and status line, `.integ-head-actions` on the right holding the relogin button(s)), rather than the caller wrapping a bare `<h3>` around a stacked status-plus-button blob. `_sites_seen` (used to build the generic Integrations card list) now explicitly excludes "glass"; a new `valuations_cards` variable builds Glass's row via the SAME `_integ_row("glass")` helper the Integrations card uses internally (so its real on/off toggle keeps working, untouched), concatenated with a plain Cazana login-only row; `_PLATFORM_LOGINS` itself was left unchanged as a shared list (other code may depend on its exact membership), Cazana is filtered out only at the point `platform_logins` is built for this one card. New `{{VALUATIONS_CARDS}}` template token and `.replace()` call, new "Valuations" card inserted between "Integrations" and the renamed "Auction house logins" card (dropped "& valuation" from its own heading and note text, since Cazana moved out; the note's own stale "four platforms" was also corrected to reflect the real five, DealerWay having joined after that sentence was first written).
    - CSS: `.integ-head`/`.integ-head-text`/`.integ-head-actions` new flex rules; `.integ-head-actions .relogin-btn`/`.relogin-done-btn` override the base rule's own `margin-top:6px`/`margin-left:6px` (needed for the Software update card's own standalone button, sitting outside this new flex context, untouched) back to 0, since the new flex row's own `gap` and `align-items:center` handle spacing there instead.
    - A REAL TOOL GLITCH CAUGHT DURING VERIFICATION, not a bug in the change itself: the first live check, in an already-open browser tab reused from earlier the same session, reported `window.innerWidth` and every element's own `getBoundingClientRect().width` as a flat 0 despite a real 1512px wide screen behind it (the same category of Browser pane flakiness already seen earlier the same session with a hung scroll gesture), making the button look wrongly stacked under the text. A fresh tab (`tabs_create` plus a fresh `navigate`) showed the real, correct picture: a genuine 1280px wide body, the login row correctly on one line, heading/status starting at x=299 and the button starting at x=846, well clear to the right. Lesson recorded here rather than trusted blindly again: when a live DOM check in this tool reports impossible 0-width geometry, open a fresh tab before concluding anything about the actual page.
    - Verified live (fresh tab): the Integrations card correctly lists only DealerKit and LE Capital; the new Valuations card correctly lists Glass's and Cazana together, exactly one of each (no duplicate Glass's row left over from the old generic loop); the Auction house logins card correctly lists the real five platforms with no Cazana row; the login row layout is genuinely side by side, not stacked. 497 tests pass unchanged (this page has no dedicated automated test coverage beyond the settings-platforms-merge check unrelated to this area, verified live only).
  - PLATFORM TOGGLE ADDED TO THE DIDN'T WIN PAGE, READY FOR CARWOW, shipped as v2.8.18, 2026-08-25, same day (Mark: "add the toggle to lost bids like home page and purchase for all, motorway carwow ready to add carwow data in to lost bids and keep ui the same"). This page never had a platform filter at all before, it has only ever read Motorway's own Didn't win screen, and its own Platform badge was hardcoded literal text ("Motorway"), not read from the row. `lost_bids.platform` was already a real column on the table (set correctly to "Motorway" by every real row on file), just never surfaced in the UI.
    - Ported the exact same tabs mechanism the cockpit and purchases page already use, verbatim: `.tabs`/`.tab`/`.tab.on` CSS, the `<div class="tabs">All/Motorway/Carwow</div>` markup, `currentTab` state, `setTab(btn)`, and `render()`'s own `rows = currentTab === 'all' ? ALL : ALL.filter(r => r.platform === currentTab)` filter line, all copied from `render_purchases()`'s own working implementation rather than reinvented. `--carwow` added to this page's own `:root` (it only had `--motorway` before) and `.plat.carwow` added alongside the existing `.plat.motorway`. The Platform badge itself changed from the hardcoded `<span class="plat motorway">Motorway</span>` to the same dynamic `(r.platform || '').toLowerCase()` / `(r.platform || '')` pattern purchases.html's own Platform column already uses, so it will read correctly the moment a real Carwow row exists, no further UI change needed then.
    - Deliberately UI only, matching "keep ui the same": no Carwow Didn't win reader was built (that needs a real live Carwow session and a real captured page, a genuinely separate piece of work), the Carwow tab today correctly shows 0 real rows, an honest "ready for" state rather than fabricated data. Verified live: the real page shows all three tabs, Motorway correctly shows all 931 real rows (100% Motorway today), Carwow correctly shows 0 with the same empty state message the page already used, and a real row's own Platform badge now reads "Motorway" from `r.platform` with the correct `plat motorway` class, not hardcoded text. 497 tests pass unchanged.
  - CROSS NAVIGATION MADE ICONS, MATCHING THE COCKPIT'S OWN, shipped as v2.8.17, 2026-08-25, same day, straight after the cross navigation links themselves were added (Mark: "on the nav bar on the purchases, settings, lost use icons to navigate to other pages like home page"). The three text links each of purchases/lost-bids/settings gained yesterday (well, earlier the same day) become icon buttons: the cockpit's own `_BAG_SVG`/`_TARGET_SVG`/`_WRENCH_SVG` for Purchases/Didn't win/Settings, plus a new `_HOME_SVG` (a plain feather-style house glyph) for "Back to the cockpit", which had no icon anywhere in this project before now. `.settingslink` CSS on all three pages changed from a text-link shape (`height:34px; padding:0 12px`) to the cockpit's own icon-button shape (`width:34px; height:34px`, no padding), title/aria-label carrying the same text the visible label used to.
    - A REAL BUG CAUGHT BEFORE IT SHIPPED: `render_purchases()` and `render_lost_bids()` both return a PLAIN (non f-string) `"""..."""` template (confirmed by checking, `render_page`'s own nav icons work because THAT function specifically returns an f-string), so the first attempt, writing `{_TARGET_SVG}` etc directly into these two pages' own markup exactly like the cockpit does, would have shipped as the LITERAL TEXT "{_TARGET_SVG}" on the actual page, never interpolated. Caught by checking each `return` statement's own type before assuming the cockpit's own pattern would just work, not live in a browser. Fixed by writing the real SVG markup out literally in all three pages (purchases, lost bids, and settings, which uses the same plain string plus `.replace("{{TOKEN}}", ...)` convention, also never touched with `{_VAR}` syntax for the same reason) rather than a variable reference, and by grepping every one of the three pages' own generated HTML afterward for a leftover unresolved `{_..._SVG}` pattern before calling it done, none found.
    - Verified live end to end against the real running server: all three pages' own real `.settingslink` elements read back with the correct `href`, `title`/`aria-label`, and a genuine child `<svg>` (`hasSvg: true` on every one, checked directly against the live DOM, not just the generated HTML string), and settings.html's own page content still loaded normally afterward (the icon change touched only the nav bar, nothing inside `#wrap`). 497 tests pass unchanged.
  - MARGIN ADDED, CAZANA 115% FOLDED INTO A HOVER POPOVER ON RETAIL EST., shipped as v2.8.16, 2026-08-25, same day, straight after the plain Cazana 115% column above (Mark: "add the margin on the retail est for margin", then, before this was built: "on hover modal", clarifying it should follow the exact purchases page pattern rather than add a third plain column). Margin here is `retail_estimate` less OUR OWN bid (`our_bid`), never `sold_for` (someone else's winning price), matching the purchases page's own "what would this car have made against MY bid" question, just asked here of the car that got away rather than one actually won.
    - This page had never used the `_infoCell`/`.info-trigger`/`.info-pop` popover mechanism before (it is a separate HTML document with its own `<style>`/`<script>`, no shared file with the purchases page), so the whole mechanism was ported over: `.info-trigger`/`.info-pop`/`.info-row` CSS (including the good/bad colour variants, `--go` already existed in this page's own `:root`, no new custom property needed) plus a matching mobile `left:auto; right:0` override correctly placed AFTER the base rule this time (inside the page's own existing `@media (max-width:760px)` block, which already sits later in the stylesheet than the newly added base `.info-pop` rule, avoiding the exact cascade-order bug found and fixed on the purchases page the same day); `_infoCell()`, `toggleInfo()` and the outside-click-closes-everything document listener, all copied verbatim from the purchases page's own implementation.
    - The plain "Cazana 115%" column added minutes earlier the same day is now REMOVED as its own column (`cazana_115` stays computed server side in `_lost_bids_with_bias()`, still needed for the popover), folded into the new Retail est. popover alongside the new Margin row instead, matching Mark's own "on hover modal" clarification rather than leaving three separate plain valuation columns.
    - Verified live end to end against the real running server: the real table header no longer carries a separate Cazana 115% column; a real Retail est. trigger reads "£12,495" with a popover correctly showing "Cazana 115% £11,955.4" and "Margin £4,673" (coloured green, `margin >= 0`); a tap correctly adds the `.show` class and a tap elsewhere correctly closes it, proven the same way the purchases page's own popovers were. 497 tests pass unchanged (this page has no dedicated automated test coverage, verified live only).
  - CAZANA 115% COLUMN ADDED TO THE DIDN'T WIN PAGE, shipped as v2.8.15, 2026-08-25, same day, straight after the Bias bid/Retail est. work above (Mark: "also add the cazana 115% value colume"). `serve._lost_bids_with_bias()` gained `r["cazana_115"] = round(r["cazana_retail"] * 1.15, 2)` when known, computed after `bias_bid` (which already correctly falls back to the row's own plain `cazana_retail` when no `cazana_115` key is present yet, unaffected by adding it here afterward), the exact same plain uplift the purchases table already stores directly as its own column, computed fresh here for the same reason `retail_estimate` is. New column in `render_lost_bids()`, positioned right before Retail est. (the figure it is derived from). Verified live: a real row reads Cazana 115% £11,955.4 immediately before Retail est. £12,495, the real formula relationship (times 1.15 plus £500, rounded to a dealer ending) visibly consistent between the two columns. 497 tests pass unchanged.
  - BIAS BID AND RETAIL EST. ADDED TO THE DIDN'T WIN PAGE, shipped as v2.8.14, 2026-08-25, same day, mirroring the purchases page's own Bias bid work earlier the same day (Mark: "on the lost bids page as the % of bias to winning bid to the 931 LOST BIDS £302 AVERAGE LOST BY (931 COMPARED) bar", then, mid build, "also add retail est to the columes").
    - `serve.py` refactored: `_purchases_with_bias`'s own pool building and per row CompMatch call were pulled out into two shared helpers, `_comp_pool()` (the real comp pool, purchases and lost bids never need to build it twice in the same request) and `_bias_bid_for_row(r, pool)` (make/model split from the row's own free text name, mileage/year/transmission/engine/grade straight off the row, car_retail from whichever of `cazana_115`/1.15 or the already unscaled `cazana_retail` the table actually has), so `_lost_bids_with_bias()` is now a thin sibling of `_purchases_with_bias()` rather than a second copy of the same logic. `/api/lost-bids` switched from a plain `db.list_lost_bids()` to this (the old duplicate route handler removed, not left as dead code). Also computes `retail_estimate` per lost bid via `purchases_run.dealer_retail_price()` (imported fresh, matching how `daily_run` is already imported the same way in this file), the exact same dealer price ending formula and rounding the purchases page's own Retail est. column already uses; lost_bids itself only ever stores the plain `cazana_retail` it was built from, never this rounded, dealer facing figure, so it is computed fresh on every request rather than added as a new stored column.
    - `render_lost_bids()`'s own row builder had a real naming collision to fix first: a local `const pct = ...` inside the row map already existed (the Lost by %), so the new shared `pct(n)` formatter (matching the purchases page's own) needed the LOCAL variable renamed to `lostByPct` to avoid shadowing it, caught before it ever became a real bug by checking the existing code rather than adding the new function blind. New Retail est. column; new summary tile "Avg. sold for vs Bias bid (N compared)", the running average of sold_for (the real price it took someone else to win, a lost bid's own equivalent of a purchase's own winning_bid) as a % of the Bias bid, same shape as the purchases page's own "Avg. winning bid vs Bias bid" tile.
    - Verified live end to end against the real running server: `/api/lost-bids` returns a real `bias_bid` for 539 of 931 rows and a real `retail_estimate` for 578 (computed in 0.05s for all 931, checked directly, no real performance concern at this volume), the real summary bar reads "931 Lost bids, £302 Average lost by (931 compared), 118% Avg. sold for vs Bias bid (539 compared)" and the table's own real header now includes "Retail est." with real values. The 118% figure makes sense on its own terms and is a useful cross check on CompMatch's own calibration: these are cars RIGHT DRIVE LOST, so the real winning prices skew above what CompMatch suggests, the opposite direction from the purchases page's own 94% (Right Drive's own winning bids, generally more disciplined than the market's), both numbers pulling in the direction you would expect if CompMatch is roughly correctly calibrated. 497 tests pass unchanged (this page has no dedicated automated test coverage, verified live only, matching the established pattern for this whole area of the project).
  - CROSS NAVIGATION BETWEEN THE FOUR PAGES, WITHOUT DETOURING THROUGH THE COCKPIT, shipped as v2.8.13, 2026-08-25, same day (Mark: "on the setting page and the purchases and lost bids use the same nav bar as the home page, so you can get to each page with having to back to the main page"). Checked each page's own current header first: purchases.html and lost-bids.html already used the modern sticky `.controls` bar (brand plus a `.controls-actions` row) with only a single "Back to the cockpit" link, no way to reach either of the OTHER two secondary pages directly; settings.html was the odd one out, still on the old dark `.topbar` (a plain h1 plus one small link) from before the modern bar existed anywhere in this project.
    - settings.html rebuilt to the same `.controls` bar shape as the other three pages (brand, a quiet `<h1>` page title where the cockpit would have its own platform tabs, `.controls-actions` on the right), the old `.topbar`/`.topbar h1`/`.topbar a` rules removed outright rather than left dead; gained the `--accent` custom property the brand's own green "Brain" half needs, the only one of the four pages that did not already define it.
    - Each of the three secondary pages' own `.controls-actions` now links to the OTHER TWO secondary pages by name (plain text links, matching the existing "Back to the cockpit" style already there, not the cockpit's own icon buttons, since introducing icon-only buttons on top of an existing text link convention would have been visually inconsistent) plus "Back to the cockpit" as before: purchases.html gained "Didn't win" and "Settings"; lost-bids.html gained "Purchases" and "Settings"; settings.html gained "Purchases" and "Didn't win". The cockpit itself already had all three via its own bag/target/wrench icon buttons, untouched.
    - Verified live end to end against the real running server (restarted so the new markup actually served): settings.html's own real page loaded with its usual Overview/platform tabs and all its existing settings intact inside `#wrap` (confirmed the rebuilt header did not disturb the page's own JS driven tab structure, which lives entirely inside that div), and each of the three pages' own real `.controls-actions` links were read directly off the live DOM confirming the right href/text pairs. 497 tests pass unchanged (none of these three pages have dedicated automated test coverage beyond the one pre-existing purchases thumbnail check, verified live only, matching the established pattern for this whole area of the project).
  - STATUS COLUMN MOVED TO A HOVER POPOVER TOO, shipped as v2.8.12, 2026-08-25, same day, straight after the status text cleanup and Bias bid work above (Mark, checking the live result: "the status should display both values if they are true", then, once shown live both values genuinely were already there via a `<br>` stack, just making some rows taller than others: "make this a roll over hover modal to display both, if the 'Collection arranged' or 'Delivery requested' status are present this show be the main status showing with both showing in the modal"). The real platform status (whatever it genuinely is, Collection arranged, Delivery requested, Checks complete, Quote accepted...) is now ALWAYS the plain, visible headline text, never hidden behind a hover; only when `payment_estimated` is also true does the cell become an `_infoCell` popover trigger (the same one Winning bid and Retail est. already use), its two rows explicitly labelled "Status" and "Payment" so both are genuinely shown together on request rather than stacked with a `<br>` that used to make that one row 65px tall against every other row's own shorter height. A row with nothing extra to add (most of them) shows the plain status text only, no popover markup at all.
    - The old `.status.est` span class (amber colour for "Estimated breakdown" inline) is dead now that the flag only ever renders inside a popover row, removed rather than left unused; a new `.info-row.est` rule gives the popover's own "Payment" row the identical amber, matching the original colour exactly.
    - Verified live end to end against the real running server (restarted so the change actually served): a real KF66GJZ row shows "Checks complete" as its own plain headline with a popover correctly reading "Status Checks complete" / "Payment Estimated breakdown"; a real GU68XPP row (no estimated flag) shows "Collection arranged" plainly with no popover at all; every sampled row's own cell height reads a uniform 65px regardless of whether it carries a popover, confirmed the old bug (a `<br>`'d row running taller than its neighbours) is gone. 497 tests pass unchanged (render only, no dedicated test coverage for this page beyond the one pre-existing thumbnail-fallback check, verified live only, matching this page's own established pattern).
  - STATUS TEXT CLEANED UP, AND COMPMATCH'S OWN "BIAS BID" ADDED TO THE PURCHASES PAGE, shipped as v2.8.11, 2026-08-25 (Mark: "lets fix the status's for carwow and motorway heres status's examples for carwow 'Docs collected, Collection arranged', motorway 'Checks complete, Delivery requested' also can you add the ai suggested bid (Bias) to the winning bid modal and show % of bias bid compared to winning bid there to then show a running average on 107 CARS BOUGHT £736,390.2 TOTAL PAID £190,967 MARGIN VS WINNING BID (45 VALUED) bar").
    - Checked the real live data before guessing at a fix: Motorway's own status text is already a real sentence ("Checks complete", "Reviewing documents"), Carwow's is a raw snake_case value straight off its own listing state ("collection_arranged", "collected", "quote_accepted"), never tidied up anywhere in the pipeline. New `humanizeStatus(s)` in the page's own JS (underscores to spaces, first letter capitalised, nothing else touched), applied only where the status is DISPLAYED, the real underlying `status` column and every other place that matches against it (`_is_cancelled` above all) are completely untouched. Verified live: a real row now reads "Collection arranged" instead of "collection_arranged".
    - "Bias bid" (Mark's own name for it): CompMatch's own suggested bid for that EXACT car, computed today against the real current comp history, shown as a new row inside the existing Winning bid popover alongside Fee/Transport/VAT/Total paid, plus a "vs Bias bid" percentage (the real winning bid as a % of the Bias bid, coloured green at or under 100%, red over, the same good/bad convention the Margin row already uses; under 100% means the car was won for LESS than CompMatch would have suggested, a good outcome). New `serve._purchases_with_bias()` (replacing the plain `db.list_purchases()` the `/api/purchases` route called directly): builds a make/model split from each purchase's own free text `name` (the exact same `split(None, 1)` pattern `purchases_run.py`'s own Cazana lookup already uses), wraps it in a `types.SimpleNamespace` carrying the purchase's own already-on-file mileage/year/transmission/engine/grade (all real columns on the purchases table since the CompMatch work earlier the same day, no new capture needed), and calls `bid_suggest.suggest_bid_for_car` against the SAME pool (`db.bid_comps_full()` plus `comp_share.load_shared_comps`) a live shortlisted car's own gauge uses, so a purchase gets exactly the same real, hard-gated, recency-windowed suggestion CompMatch would give it today, not a separate or looser calculation. Built fresh on every request, never cached, matching this whole page's own "always live" convention.
    - The new summary tile ("Avg. winning bid vs Bias bid (N compared)") is the running average of that same % across every purchase where both figures are known, the same "(N valued)" shape the existing Margin tile already uses. Verified live: 88 of 107 real purchases got a real Bias bid (the other 19 genuinely have no comparable history yet, or a car whose own transmission has none of its own kind in the comp pool, both honest gaps, never guessed), real average 94%, meaning winning bids are typically coming in under CompMatch's own suggestion across the board, a real, useful signal on CompMatch's own accuracy as buying advice, not just a curiosity metric.
    - Delegation note: this touches CompMatch's OUTPUT (calling the existing `suggest_bid_for_car` from a new call site) but not its own internal logic, so it was done directly rather than routed to Fable per the standing "CompMatch edits go through Fable, everything else stays Sonnet" preference (Mark 2026-08-25, saved to memory) — no changes to `bid_suggest.py` itself were needed or made.
    - Verified live end to end against the real running server (restarted so the new serve.py/render.py code actually served): `/api/purchases` returns a real `bias_bid` per row, a real popover shows "Bias bid £10,852" and "vs Bias bid 95%" correctly coloured green, the real summary bar reads "94% Avg. winning bid vs Bias bid (88 compared)". 497 tests pass unchanged (this page and its API route have no dedicated automated test suite beyond the one pre-existing thumbnail-fallback check, verified live only, matching this page's own established pattern for anything needing the real database or a live server).
  - PURCHASES PAGE TIDIED UP FOR SMALL SCREENS, shipped as v2.8.10, 2026-08-25 (Mark: "tidy up the purchases screen so it fits on small screens, as the screen goes resposive to small screens, put the fee, transport, vat, total paid on a modal that is viewable on hover of winning bid, do the same to cazana 115% & margin modal on hover to Retail Est, then only show one image of v5 and service history, to view click and open model"). From 17 columns down to 11, `render_purchases()` only.
    - Winning bid's own fee breakdown (Fee, Transport, VAT, Total paid) and Retail est.'s own Cazana 115%/Margin both moved off their own columns into a small popover (`_infoCell`, new JS function): shown on `:hover` via plain CSS, and, since a touch screen has no hover to trigger with, also toggled by a tap (`toggleInfo`, adds/removes a `.show` class, a document level click listener closes whichever is open when the user taps anywhere else, the same behaviour a native dropdown would have). `margin` (previously built straight into an HTML string, `marginCell`) is now kept as a plain number so both its formatted figure and its good/bad colour class can be reused inside the popover row.
    - Service history and V5 (`_docCell`, new JS function, replacing the old inline `.map()` over every photo in both columns) now show only the FIRST document photo as a real clickable thumbnail, with a small count badge in the corner when there is more than one (Mark: "only show one image... to view click and open modal"); the full list is still passed through to the existing shared gallery modal via the same `data-gallery` attribute as before, `openGallery` and the modal itself untouched, only the ROW no longer shows every photo inline. The old `.history-thumbs` flex row CSS (capped to 140px with its own horizontal scroll, a 2026-08-24 fix for a long service history pushing the table wide) is now dead, removed rather than left unused; `.history-thumb` itself stays, upsized from 30x30 to 44x32 now that it is the only one per cell.
    - A REAL BUG FOUND AND FIXED before shipping, caught by directly inspecting the live computed CSS via `javascript_tool` rather than trusting the screenshot tool (which hung/timed out repeatedly this session trying to scroll the real page, an environment flakiness unrelated to the change itself, worked around by verifying through direct DOM/JS inspection instead): the new mobile specific `.info-pop { left:auto; right:0; }` override (meant to keep a popover from running off a narrow screen's own right edge) was added inside the file's EXISTING early `@media (max-width:760px)` block, which sits BEFORE `.info-pop`'s own base rule later in the same stylesheet, so the later, unconditional base rule always won the cascade regardless of screen width, silently doing nothing at any size. Fixed with a second, separate `@media (max-width:760px)` block placed AFTER the base `.info-pop` rule, confirmed live on a real 375px viewport: `getComputedStyle` read `left:auto; right:0px; min-width:140px` correctly, versus the base desktop `150px` before the fix.
    - Verified live end to end against the real running server (com.bidbrain.serve restarted so the new render.py code actually served, the same "the process caches the old module until restarted" lesson already on record for this project): a real "£10,322" Winning bid trigger correctly nests Fee/Transport/VAT/Total paid rows, a real "£16,495" Retail est. trigger correctly nests Cazana 115%/Margin; a tap correctly adds the `.show` class and flips the popover's own computed `display` to `block`, a tap anywhere else correctly closes it; a real service history thumbnail carries its own full 4 item gallery array and clicking it opens the shared modal reading "1 / 4"; at a real 375px mobile viewport the page's own `body.scrollWidth` never exceeds the viewport (no whole page horizontal scroll) while `.tablewrap` itself still correctly scrolls sideways within its own bounds, the same documented fallback this page has used since 2026-08-24, now needed less often with 6 fewer columns and a single thumbnail per document column. Generated JS re-checked with `node --check` both before and after the cascade fix (the first version had a separate, unrelated Python string escaping bug of its own, `\'` written where the existing house convention needs `\\'` since this whole page is JS embedded inside a Python triple quoted string, caught by the same `node --check` step, not live in a browser). 497 tests pass unchanged (this page has no dedicated test suite beyond the one pre-existing thumbnail-fallback check, which was left untouched and still passes; render only, no gate or pricing logic anywhere near this change).
  - COMPMATCH SECOND PASS: A RECENCY WINDOW, CANONICAL MODEL KEYS, AND THE MANUAL/AUTO GATE ON BUCKETS, 2026-08-25 (Mark: "ok have another go at a model for suggested bid"). Three real, live-verified problems found by investigating the actual data rather than theorising, all in bidbrain/bid_suggest.py plus one column each in db.bid_comps_full and comp_share:
    - THE BIG ONE, A SYSTEMATIC AGE BIAS: the Didn't win history reaches back 58 MONTHS, and every comp's own Cazana retail was looked up NOW (the 2026-08-25 backfill) while its sale happened up to 5 years ago, so depreciation systematically inflates old comps' own %-of-retail figures. Measured month by month against the real 578 dated lost-bid ratios: median 0.76 under a month old, 0.81 around 18 months, 0.92 by two years, 1.2+ at three to five years, with 100% of comps past 40 months exceeding the MAX_PLAUSIBLE_RATIO cap. So the "stale or mismatched Cazana lookups" blamed earlier the same day for implausible ratios are mostly just OLD comps priced against today's depreciated retail, and old raw amounts (which previously flowed into the flat fallback completely UNFILTERED, a 4 year old £10k sale counting as a £10k comp today) are prices from a market and a car age that no longer exist. Fix: COMP_MAX_AGE_MONTHS = 18, a hard window, a comp older than that (or with no readable date, excluded rather than assumed recent, hold back rather than guess) never contributes a price figure at all, ratio or raw; within the window a small recency bonus (0.5 tapering to 0, deliberately smaller than any single spec factor) breaks ranking ties toward the current market, counted separately from the like_for_like decision which stays on spec similarity alone. db.bid_comps_full gained date (bought_date/date_bid); comp_share._SAFE_FIELDS gained "date" and this Mac's own shared_comps/right_drive.json was re-exported with it (an export without dates now contributes nothing to the other dealership's window, said plainly in comp_share's own comment and the README).
    - MODEL KEYS WERE SPLITTING REAL GROUPS: BMW comp names carry the engine badge as the model word ("118D", "320D"), so every BMW comp keyed to its own tiny badge group while a live "BMW 1 Series" car keyed to "BMW 1" and found ZERO of the 27 real 1 Series comps on file (9 live cars affected on the day's list alone); Mercedes was split three ways ("MERCEDES A" 34 + "MERCEDES A180" 7 + "MERCEDES-BENZ A-CLASS" 8, one real model, 51 comps once merged); "MG Motor UK ZS" keyed to "MG MOTOR" and missed the real "MG ZS" group; 2 comps carry Motorway's own truncated "Insig" for Insignia. model_key now canonicalises exactly the way render._model_family/_canon_make_map already solved this same problem for the cockpit's filters (regex shapes deliberately duplicated, not imported, bid_suggest stays pure and render depends the other way). Measured live: +34 suggestions from this alone before the window was applied.
    - THE HARD TRANSMISSION GATE NOW MATCHES ON THE MANUAL VS AUTOMATIC BUCKET (_tx_bucket), not the exact platform string: Semi-automatic buckets WITH Automatic (it is an automatic from a buyer's and a value point of view, and pricing.py's own class rules already treat "Automatic or Semi automatic" as one family), manual stays pure, nothing else ever buckets into it, so Mark's own "never mix manual and auto" hard gate is fully intact, this only stops the 13 real Semi-automatic comps and the 24 live Semi-automatic cars being stranded in a third bucket too small to ever match anything. Matched on the word ("manual" in the string) rather than equality so a future "6 speed manual" style value could never silently bucket as automatic; checked live the only real values anywhere are Manual/Automatic/Semi-automatic/None.
    - NET RESULT, measured on the real 2026-08-25 cached list (484 shortlist+held cars) old code vs new, exact reimplementation of the old model as the baseline: 126 -> 131 suggestions (124 now rendered on the real cockpit page), but the composition is the real story: 29 gained (the naming merges and the semi-auto bucket, including the first ever BMW 1 Series and properly pooled Mercedes A-Class suggestions, whose manual vs automatic figures now correctly split, manual A-Class around £6.2-7.2k vs automatic £8.6-10.7k on the same day's page), 24 lost, every one checked and genuinely built on stale comps only (manual Astras: 16 of 20 comps were 22-55 months old; manual Golfs 7 of 8; those figures were exactly the "way off" category), and the 102 kept cars moved a median -5.1% (the stale-comp inflation coming out, single worst case -24.6%). CE16DVT, Mark's own "particularly silly" example Kuga from earlier the same day (£10,807 suggested against £8,769 retail before that day's cap fix), now reads £6,954, ratio based on 10 real recent comps, like for like, transmission matched.
    - DELIBERATELY NOT DONE: no cross-model anything (the confound found and rejected earlier the same day stands); no softening of min_samples=5 even though several lost models sit at exactly 4 recent comps (they cross the floor naturally as fresh bids accumulate daily); no adaptive percentile (70th kept uniformly, the windowed data behaves and an adaptive choice would be harder to explain than it is worth at this volume); no age-correction formula to rescue old comps' ratios (a fitted drift adjustment is a guess wearing a lab coat, the window is plain and honest); MAX_PLAUSIBLE_RATIO = 1.05 kept as a backstop inside the window (rechecked: within 18 months 0% of real comps exceed it, it now only catches a genuinely bad retail figure rather than doing the window's job badly). A 24 month window was measured too (147 suggestions but with comps carrying ~+10% measured ratio drift in the 18-24 band) and turned down in favour of accuracy; noted here in case Mark ever wants the trade the other way. 497 tests pass (21 new: the canonical keys, the bucket gate both directions including manual purity, comp_age_months never guessing, the window excluding old and undated comps, an all-old model correctly getting no suggestion at all, and the comp_share export carrying the date).
