# BidBrain project status, part 5: v2.2 and v2.3 (21 to 23 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.

**v2.2 starts here, 2026-08-21 (after the v2.1 tag was cut).**

  - REAL VERSIONED RELEASES ON GITHUB, 2026-08-21 (Mark asked whether the changelog could be tied to GitHub's Releases section). Created git tags v1 (the repo's own first commit, the actual original build predates this git history), v2 (the last commit before the roughly 3am 2026-08-20 boundary already marked in this file) and v2.1 (the tip at the time, after the README rewrite and CHANGELOG.md were added), each an annotated tag with a short message, then a real GitHub Release for each via the gh CLI, notes pulled straight from the matching CHANGELOG.md section so the two never drift apart. gh itself was not installed and this plan's tier has no branch protection available (checked via the API, 403 Upgrade to GitHub Pro), so gh was downloaded directly as a binary (same no Homebrew pattern as cloudflared earlier in the project) to ~/.local/bin/gh, then authenticated via a device code flow with Mark completing the browser side. SETUP_FOR_NEW_USER.md's first step now tells a new installer to clone and checkout the latest release tag rather than main, with the update path (git fetch --tags, checkout the new tag) spelled out, since main moves as work in progress and was never meant to be installed from directly.
  - CI ADDED, AND IT CAUGHT A REAL BUG ON ITS FIRST RUN, 2026-08-21 (Mark asked whether two people pushing to this shared repo would both go live safely; the honest answer was git's own non fast forward push protection covers the obvious case, but nothing here ran the test suite automatically, and CLAUDE.md itself is a single append only file every session edits, a real conflict risk). Added .github/workflows/tests.yml: on every push and pull request to main, installs playwright, copies dealer_config.example.py to dealer_config.py (purchases_run.py imports it at module level so the suite needs one to exist, no test asserts on real account values), and runs test_pricing.py. Verified against a genuinely clean clone of the exact same steps before ever committing it.
    - The very first real run failed, not a CI setup mistake: auction4cars.parse_listing converts each lot's raw epoch timestamp with datetime.fromtimestamp, which reads the LOCAL system timezone, correct for how this actually runs (a single Mac, in the dealer's own timezone), but a test had hardcoded the resulting UK wall clock string as though it were fixed. The GitHub Actions runner defaults to UTC and read the same epoch an hour earlier, failing there while passing locally. Fixed the test to compare against the same conversion the code itself performs on the same raw epoch, rather than a hardcoded string, verified passing under both the real local timezone and a forced TZ=UTC locally, so it is correct on any runner regardless of timezone. No code change needed, the underlying local timezone conversion is correct for the single Mac deployment model, only the test was wrong to assume one fixed timezone.
  - AUCTION COUNTDOWN NOW TICKS BY THE SECOND, 2026-08-21 (Mark: "it currently shows 'ends in 16m' not a clock counting down by the second"). paintCountdowns repainted every 30 seconds at minute granularity regardless of how close a lot was to ending. Now repaints every second; a lot under an hour out shows Xm Ys and genuinely ticks live, an hour or more out stays at Xh Ym, seconds there would just be noise. Applies to both Auction4Cars and Dealer Auction, the only two platforms with a real per lot end time. Verified live: watched a real badge drop from 47s to 37s across a 3 second wait.
  - AN "ENDS WITHIN" COCKPIT FILTER, AND THE READER BEHIND IT WIDENED TO MATCH, 2026-08-21 (Mark: Auction4Cars and Dealer Auction run over multiple days, unlike Motorway and Carwow's fixed 3:30pm close, so add a filter for how many days ahead to show, today, tomorrow, 3 days, 4 days etc). Auction4Cars' reader had only ever captured lots ending today or tomorrow, dropping anything further out before it ever reached the shortlist, so there was nothing beyond that for a wider filter to show. Widened auction4cars.parse_listing/read_all_pages/read_live to a configurable days_ahead window, default 7, the longest a real auction here runs, so nothing is missed at read time. Fixed a latent bug found in the same change: the old window check tested membership against a 2 element tuple (today, tomorrow), which only ever worked because there were exactly two valid dates, replaced with a real inclusive date range. New sidebar dropdown (Ends within: Today, Tomorrow, 3 to 7 days) filters any card carrying a real end time to a rolling window measured from right now, the same live clock the countdown badge itself uses, not a calendar day boundary; a card with no end time (Motorway, Carwow) is unaffected, the concept does not apply to it. Wired into the same filter, reset and saved view plumbing as every other sidebar filter. Verified live: the dropdown renders with all seven options, selecting Today correctly narrowed Dealer Auction while leaving Motorway and Carwow untouched, and Within 7 days plus Reset both restore the full count. New synthetic fixture card 11 days out proves the widened default still excludes something, it did not just become unlimited.
  - A FAILED PLATFORM READ COULD SILENTLY WIPE ITS OWN CARS, FOUND LIVE AND FIXED, 2026-08-21. Found while verifying the window widening above: Auction4Cars' saved login had genuinely gone dead, and a partial run refreshing just that platform (python3 daily_run.py --platforms auction4cars) caught the failure in its own try or except, printed a line, and returned an empty list, indistinguishable at the call site from "genuinely nothing ending soon". Since the platform was selected, not excluded, nothing carried its previous cars forward either, so it silently disappeared from the shortlist, held and rejected buckets entirely, real data loss with only a console line as evidence, exactly what golden rule 5 exists to prevent. Reproduced live: Auction4Cars dropped from 101+ cars to 0 in one run. _read_carwow, _read_auction4cars and _read_dealerauction (the three readers that already deliberately swallow their own failures so one dead login does not take the whole run down) now return None specifically when the read itself failed, distinct from a real empty list. run() now loads the previous cache unconditionally, not just for a partial run, and falls back to that platform's own last known good cars on None, the same way an excluded platform is already carried forward, printing a loud message rather than staying silent. review()'s own call site, which would otherwise crash concatenating None, coalesces to empty instead, it has no previous run of its own to fall back to. Verified live with a controlled simulated failure (monkeypatched _read_carwow to return None, ran a real partial run): 529 real Carwow cars survived identically before and after, versus wiping to 0 without the fix.
    - Real world postscript, same day: a genuine "Run now, Auction4Cars and Dealer Auction only" from Mark landed moments before this fix was actually live, so both platforms' own live reads failed (Auction4Cars timed out, Dealer Auction's session had also died, redirected to an OAuth login page) against the OLD code, wiping both to zero for real before the fix could protect them. A run after the fix correctly showed the new "Falling back to the last known good cars" wording and DID fall back, proving the fix works, but found 0 cars to fall back to, since the prior wipe had already emptied the snapshot it was falling back to. The fix prevents this going forward, it cannot undo damage already done. Root cause was three dead logins, not the code: Auction4Cars, Dealer Auction and (separately) DealerKit had all quietly expired. All three re logged in the same day (python3 login.py auction4cars manual, dealerauction manual, dealerkit manual, each with Mark present at the keyboard as the golden rule requires), DealerKit's password having rotated on its monthly cycle in the middle of it. All three sessions confirmed landing on a real logged in page (Auction4Cars on /home, Dealer Auction on its real sourcing search, DealerKit on /dashboard) and saved fresh.
  - AN "ENDING SOONEST" SORT NEXT TO THE CAZANA PRICE SORT, 2026-08-21 (Mark: as Auction4Cars and Dealer Auction have differing auction ending times it makes sense to sort these by ending first, create a new sort option and place next to Cazana sort, default ending first for a4c and dealer auction). New standalone toggle chip (_ends_sort_btn), the same shared, independent of Sort and any filter pattern as the existing Cazana price sort, mutually exclusive with it, only one manual override can be in charge at a time, picking one turns the other off everywhere. Beyond the manual toggle, sortCards now also takes the section's own platform key: on the untouched default sort (Best opportunity, nothing chosen from the dropdown or either override chip), Auction4Cars and Dealer Auction sections order by end time automatically, since Best opportunity says nothing about how much time is actually left to bid on a multi day auction; an explicit dropdown choice still applies everywhere uniformly. A card with no end time (Motorway, Carwow) sorts to the back rather than being hidden, this is an ordering, not a filter. Fixed paintPriceSort's own selector along the way, it was painting every .sortdirbtn including the brand new one, both toggles now target their own class. Verified live: both chips render on every section heading, Dealer Auction's cards were already in ascending end time order on load with no clicks, the new default, Motorway stayed on its normal Best opportunity order unaffected, and toggling either chip correctly turns the other off everywhere.
  - A CARD DISAPPEARS ONCE ITS AUCTION ENDS, 2026-08-21 (Mark: when an auction ends on a4c and dealer auction, remove the car from view). Previously the countdown badge just switched to a greyed out "Auction ended" label and the card stayed on the shortlist. Added a notEnded check into the same okFilters predicate every other sidebar filter already goes through (a card with no end time, Motorway or Carwow, is unaffected, same not applicable pattern as the Ends within filter), so an ended card is excluded from view on load and stays excluded through any later filter or sort interaction, not a one off style.display toggle fighting the real filter pipeline. For a card that ends while the page is already open, paintCountdowns (already ticking every second for the live clock above) now notices the exact tick a card crosses into ended and asks applyView to re run once, so it disappears live rather than waiting for the next filter click. Verified live: a real card set to end 1.2 seconds in the future vanished within the next tick with no manual interaction, and a card given a past end time was hidden immediately once re filtered.
  - PUSH PURCHASED CARS INTO DEALERKIT AS DUE IN, BUILT and proven live, 2026-08-21 (Mark: "the next step is to get these missing cars that have had their bids accepted on motorway added to Dealer kit as due in with all reletive purchase info... add the 4 images and V5 and service history to dealer kit", scope confirmed as a manual, reviewed trigger first ("lets do a test to confirm everything works first then automatic"), not wired into any automatic run yet). This is BidBrain's first ever WRITE into DealerKit, a real business system other staff see and rely on, so it was built the same way every reader in this project has been: real live reconnaissance first (clicking through the real Add Vehicle flow, a real Edit Vehicle menu, real Images and Documents upload widgets, captured to data/inspect), never guessed markup, and confirmed with Mark before running anything that writes for real.
    - Golden rule 1 (never take a platform action) does not cover this: DealerKit is Right Drive's own DMS, already read only integrated for stock counts, not one of "the platforms" (Motorway, Carwow, Auction4Cars, Dealer Auction, Glass's, Cazana) the golden rules are scoped to. Still treated with the same caution as a real financial write, confirmed with Mark step by step during the build rather than assumed.
    - New bidbrain/dealerkit_write.py. DealerKit is a Vue single page app whose form fields carry no name, id or placeholder worth relying on, and several components render as HIDDEN DUPLICATE INSTANCES of the same modal at once (found live: 6 copies of the registration field, 10 copies of a "Next" button, only one ever actually on screen), so every click here finds the one currently VISIBLE match by real bounding box rather than trusting a fixed selector or index, never a plain .first. add_due_in(page, reg) opens the real "+ Vehicle" button (a.pill-green, itself found the same visible-match way after the DOM only #new hash link turned out to be present but not directly clickable), types the reg, clicks Next, and listens for the real network response to know what happened: a 201 means DealerKit created a genuine live stock record immediately, no draft state (proven live on AY66WFW, a real due in record existed the instant Next was clicked); a 422 with "The vehicle is already in the stocklist" means someone on the team already added it by hand (proven live on HG17NRF, a real Vauxhall already Due In under RightDrive Car Finance), a normal outcome, not an error, so this feature has real gaps to fill rather than duplicating what staff already do. On a genuine creation, the Purchase Details step (Bought From, invoice ref, purchased on, purchase price, VAT qualifier, finance settlement, funding, sales location) is deliberately SKIPPED, nothing on it is required (a Skip sits next to Save) and matching or creating a DealerKit "Bought From" contact was not attempted, safer left for Mark to fill by hand. Real bonus found live and NOT built by this feature at all: DealerKit has its own live Motorway integration, once a reg is recognised it auto adds a "Purchased ... From Motorway" History entry and fills in Purchase Price and fees on its own (seen on AY66WFW after just the bare due in creation, and confirmed by inspecting a real already established stock car, LX16 WXY, whose own History shows the identical "From Motorway" entry from whenever it was first added).
    - Images and documents both live on the vehicle's own record, reached via a real Edit Vehicle menu (Description, Pricing, Images, Video, Advertising, Mileage Log, Documents, Expenses, Finance Settlement, VRMs, VAT Reclamation, Miscellaneous, Write Downs), opened from a toolbar EDIT button. upload_images(page, vehicle_id, photo_urls, max_images=4) and upload_documents(page, vehicle_id, document_urls) share one _upload_via_edit_tile: open the vehicle's overview page, open Edit, open the Images or Documents tile, download each url through the BROWSER'S OWN request context (page.context.request.get, not Python's urllib), attach them to the modal's real file input, click Save Changes. The four exterior photos are simply the first four of the purchase's already captured photo_urls (front/rear driver/passenger, the same gallery built earlier the same day for the cockpit and purchases page); service history is whatever service_history_photos already holds. V5 is explicitly NOT uploaded, no V5 image has ever been captured anywhere in this project (deliberately, it is seller PII on a not yet purchased listing) and sourcing one for an already bought car is separate, unstarted work.
    - Two real bugs found live and fixed, both because the very first manual test (2026-08-21, on AY66WFW, the same day Mark first tried it) silently produced a due in record with real Motorway purchase info but ZERO images or documents, caught by Mark noticing and reported back rather than assumed working: (1) urllib.request.urlopen failed every single download with SSLCertVerificationError, a stock Python on macOS issue (no trusted local certificate store), not a DealerKit or imgix problem, and the first version of _download_temp did not guard against it, so the whole push silently died right after creating the due in record, before ever reaching an upload. Fixed by fetching through page.context.request instead, the same network stack that already renders these exact photos everywhere else in the app, verified live to fetch a real photo (200 OK) where urllib had failed. (2) Even after that fix, the EDIT button click still failed: its visible label sits in the same text node as a material icon ligature ("mode_edit\nEDIT"), so no element's own text is ever exactly "EDIT" and page.get_by_text("EDIT", exact=True) matched nothing at all (proven live, count 0), raising loudly rather than clicking the wrong thing, which is why the run visibly failed to attach anything rather than attaching to a wrong target. Fixed by switching to the same "smallest visible element whose text CONTAINS the target" helper already used for search result cards, broadened to also scan button and span elements. The file input targeting was tightened at the same time, scoped to the one currently open modal (".ui-modal.is-open input[type=file]") rather than a bare page wide .first, since a busier real vehicle overview page (job card, inspections, comments) can carry other hidden file inputs besides the one just opened.
    - Verified live end to end on AY66WFW (a real Motorway purchase, status Reviewing documents, matching Mark's own "once its reviewing docs status" trigger condition) after both fixes: attached 4 real files to the Images modal without saving first and watched DealerKit's own header count update to "Edit Vehicle Images (4)" client side, proving the mechanism before ever committing anything, then Mark ran the real push himself (python3 purchases_run.py --push-dealerkit AY66WFW) and confirmed it worked; independently re opened the real record afterward and read "Edit Vehicle Images (4)" and "Edit Vehicle Documents (2)" straight from DealerKit's own UI, matching AY66WFW's real captured gallery and service history exactly.
    - Manual trigger only, per Mark's own plan: python3 purchases_run.py --push-dealerkit REG opens a real HEADED DealerKit window (so it can be watched) and pushes that one purchase. Not wired into daily_run.py or any scheduled job. Still to do once Mark is ready to trust it for more cars: an automatic trigger keyed on purchases.status reaching "Reviewing documents", and a real decision on sourcing a V5 image for an already bought car.
    - FINANCIALS (Purchase Price, Retail Price), Mark asked to "add this in" same day, ended up UNRESOLVED after real live investigation, left honestly documented rather than shipped half working. First attempt: typed BidBrain's own winning_bid and retail_estimate into the Purchase Details step's Purchase Price and Retail Price fields before Save (a new _fill_labelled_field, clicking the label text and relying on the browser's native label click forwarding to reach the sibling <input>). Tested live on KF66GJZ: reported success, but DealerKit's own API (GET /api/stocklist/<id>) showed purchase_cost and retail_price both still null, a real silent failure caught by checking the API directly rather than trusting the UI or the function's own return value, since the blurred Financials panel would not reliably reveal itself in headless script screenshots to cross check by eye. Root cause found and fixed: this custom Vue component does not forward a label click to its input the way a plain HTML label would, rewrote _fill_labelled_field to locate the real <input> by DOM traversal from the matching label and click it directly, verifying the typed value actually landed (input.value read back) before ever clicking Save.
    - That fix was never proven right, because a second live test (Mark, in DealerKit's own UI, deleting and re adding AY66WFW by hand to get a clean Stocklisting step to watch) showed the real mechanism is entirely different from what was assumed: Purchase Price (£4,266.00, exactly BidBrain's own price for that car) and Retail Price (£8,495.00) both appeared with NOBODY typing anything at all, purely from DealerKit's own live Motorway integration firing once it recognised the reg (the same integration first spotted 2026-08-21 earlier the same day via LX16 WXY's History entry, see above). So typing our own figures into those fields was not just unreliable, it was actively wrong, at best redundant with what DealerKit already does itself, at worst racing or blocking DealerKit's own async write (a field a user has typed into may simply never be overwritten by the auto fill). _fill_labelled_field and the whole typing approach were removed outright (not left disabled) per house style, "never keep something proven wrong around as dead code". add_due_in now always just clicks Skip on the Stocklisting step, same as the original build, and push_purchase gained wait_for_dealerkit_financials(page, vehicle_id, timeout=30, poll_every=3), which polls DealerKit's own stocklist API (never the masked UI) for purchase_cost and retail_price after a genuine new creation, on the theory that DealerKit's own lookup just needs a little real time, some of which the image and document uploads already spend.
    - THAT theory was also NOT confirmed. Two further live checks (KF66GJZ, and a fresh car BU16WZB pushed through the fixed poll-based code) both showed the auto fill genuinely never arriving, checked directly against DealerKit's real API and UI (Expenses tab reading "No Expenses", Retail Price still £TBC), not just an unlucky poll window. So the honest current state, as of 2026-08-21 evening: DealerKit's own Motorway integration DOES exist and DOES sometimes fill in Purchase Price and Retail Price with zero input from BidBrain (proven, AY66WFW, added by Mark's own hand in the real UI), but it has now also been seen NOT to fire at all for two other real cars pushed the same way through the automated script, with no distinguishing factor identified yet (not the wait length, not "Skip vs never having a value typed", not obviously the purchase status). wait_for_dealerkit_financials is left in place since it is harmless and correctly reports null rather than claiming success when nothing lands, and purchases_run.py's own printed summary says so plainly rather than asserting success. Genuinely unresolved: whether this needs a longer wait, a different trigger DealerKit is waiting on, or is simply flaky on DealerKit's side; needs another live session with Mark watching a fresh automated push in real time (not after the fact) to catch the moment (or non moment) it happens. Until then, Purchase Price and Retail Price on a DealerKit push should be treated as "sometimes there, check the record, do not assume."
    - A THIRD attempt, same evening, watched live and slowly this time rather than fired and checked after the fact: with Mark logged into DealerKit in the Claude Code Browser pane, Claude drove the real Add Vehicle flow by hand (registration DH66KCV, a real Skoda Fabia purchase, click by click, screenshotting each step) rather than running the headless script, specifically to test whether being a genuinely interactive, slower, human paced browser session (as opposed to a fast automated one) was the missing factor behind AY66WFW's one success. It was not: created cleanly (vehicle id 333, confirmed via GO TO VEHICLE), Skip clicked on the Stocklisting step same as always, then checked the real record repeatedly over roughly 70 seconds (waits chained since a single wait call is capped at 10s) and Retail Price still read £TBC and Expenses were still empty throughout. So being interactively driven rather than scripted is not the differentiator either, at least not within a 70 second window. Mark's own call on how to proceed: leave DH66KCV as is and check back on it later in a future session rather than keep polling live, so the question of whether this just needs several minutes (matching how long Mark's own real add to AY66WFW likely took end to end, delete plus re add plus him narrating each step to Claude) remains open. DH66KCV, vehicle id 333, is a live real due in record with no images or documents attached (this was a UI only test of the create and Skip step, not a full push_purchase run) and is a good one to re check first in a future session.
    - RESOLVED, same evening, once Mark ran the whole add by hand a second time. Mark deleted HG17NRF and re added it himself from scratch (Claude opened a real native Chrome window plus a full size Browser pane tab for this, both blank, Mark logged into the Browser pane one and drove the entire add himself while Claude watched). Result: Retail Price showed a real figure immediately (£8,495, which Mark then said he had typed himself from BidBrain's own estimate, not auto filled) and, checked on the Expenses tab, a full itemised breakdown had appeared with NOBODY having typed it: Purchase £4,322.00, Delivery Charge £110.00, Buyers Premium £299.00, Assurance/Indemnity Fee £53.99, each linked back to "Motorway - #1433" through "#1436". Cross checked against BidBrain's own independently captured purchases table for HG17NRF (purchases_run.py's own Motorway payment breakdown reader, unrelated code, read on a different day): winning_bid 4322.0, transport_fee 110.0, motorway_fee 299.0 match the Purchase, Delivery Charge and Buyers Premium EXACTLY to the penny; protect_fee 44.99 times 1.2 (adding VAT) is 53.988, matching the Assurance/Indemnity Fee almost exactly, so DealerKit's own figure is the same one gross rather than net. Real, matched documents also appeared (an invoice and what reads as a genuine V5C, "UNITED KINGDOM REGISTRATION CERTIFICATE", HG17NRF printed on it). This confirms the Motorway integration is real and accurate, contradicting nothing typed by Mark, only the Retail Price came from him.
    - Checked DH66KCV again in the same session (by then a good 15+ minutes since it was created): STILL "No Expenses" and "No documents attached", both completely untouched. So the full picture across five real attempts is: AY66WFW (Mark, by hand) auto filled; HG17NRF (Mark, by hand) auto filled, expenses and documents both, verified against BidBrain's own figures; KF66GJZ (Claude, headless script) nothing; BU16WZB (Claude, headless script) nothing; DH66KCV (Claude, driving the Browser pane click by click, slowly, screenshotting every step) nothing, even after 15+ minutes. Two for two when Mark drives it by hand, zero for three when Claude drives it, scripted or not. That is a real pattern, not a timing coincidence: DealerKit's own Motorway integration appears to only fire for a genuinely human driven browser session, not an automated one of any kind, headless or CDP driven through the Browser pane. Not proven WHY (could be navigator.webdriver or another automation fingerprint DealerKit or Motorway's own webhook checks for, could be something else about real OS level input events), but the practical conclusion is solid enough to act on.
    - CONSEQUENCE for the automated push_purchase path: relying on DealerKit's own auto fill for Purchase Price and Expenses is very unlikely to ever work from an automated run, headless or not. If Mark wants this filled in automatically rather than by hand, the honest path is back to TYPING BidBrain's own figures into the Stocklisting fields during add_due_in, the exact mechanism that was built, proven to work mechanically (verified input.value landing before Save), and then deliberately removed earlier the same day on the theory that DealerKit's own integration made it redundant. That theory only holds when a human adds the car; for the automated script it does not. NOT re implemented yet, since the removal reasoning needs to be corrected first and Mark has not yet said whether he wants typed in figures back for the automated path, or wants to keep leaving Purchase Price and Expenses for himself to fill by hand every time. Flagged here rather than decided.
    - DECIDED, same evening, Mark: type it in automatically. Re added _fill_purchase_price to dealerkit_write.py, the same DOM traversal approach the earlier removed _fill_labelled_field used (walk up from the matching label text to the nearest ancestor holding a real <input>, click that input directly rather than the label text, then read input.value back to confirm the number actually landed before ever clicking Save), scoped to ONLY the Purchase Price field this time, not Retail Price. Retail Price stays untouched by the script on purpose, that is Mark's own market call, and he already types it in by hand when he adds a car himself. add_due_in(page, reg, purchase=None) now: on a genuine new creation, tries BidBrain's own winning_bid or price as the Purchase Price; if it types and is confirmed landed, clicks Save, otherwise Skip, same as before. Only the single combined figure is set, not an itemised Buyers Premium / Delivery Charge / Indemnity Fee breakdown like DealerKit's own integration produces, since that needs a separate Add Expense flow not yet investigated, a known gap versus what a human adding the car gets automatically. push_purchase's own wait_for_dealerkit_financials call is now skipped whenever the typed price was confirmed (no need to poll for something already known), only used as a harmless fallback check when there was no confident BidBrain figure to type in. purchases_run.py's printed summary distinguishes "entered from BidBrain's own figures" from "DealerKit filled in its own" from neither, and always notes Retail Price is left for Mark. 256 tests pass (this module has no dedicated test suite, it is all live browser interaction verified against the real site as described above, not unit tested).
  - DEALERKIT WIDE FINANCIALS AUDIT, 2026-08-22 (Mark: "can you check all cars in dealer kit to see if the purchase price, auction fee, indemnity and delivery fees are present and correct"). Read all 191 vehicles in the real DealerKit stocklist (a live authenticated fetch loop run from the Browser pane's own JS console, not Claude's automation session, since that saved session had gone dead again by then, same known fragility as every other platform in this project), via /api/stocklist (offset paginated, 25 per page, no dealer_id or auth header needed beyond the browser's own session cookie) and /api/stocklist/<id> for each car's real purchase_cost.expense.items.
    - Correctness: cross checked the 22 cars where BidBrain has its own independently captured Motorway figures (winning_bid, motorway_fee, transport_fee, protect_fee, from purchases_run.py's payment breakdown reader, wholly separate code from anything DealerKit related) against DealerKit's stored amounts. Zero discrepancies, every figure matched to the penny once VAT on the indemnity fee is accounted for. So wherever DealerKit has data, it is accurate, the real gap is completeness not correctness.
    - Completeness: 59 of 191 cars have all four (purchase price, delivery charge, buyers premium, indemnity fee); 53 have only the base purchase price line, no itemised fees at all (older cars, likely predating whatever triggers the itemised breakdown); 59 have some fees but are missing at least one type (indemnity missing on 101 cars overall, delivery on 90, buyers premium on 61); 20 have no purchase cost recorded at all, three of which are BidBrain's own known automated push test cars (KF66GJZ, BU16WZB, DH66KCV). CAVEAT found partway through and worth remembering: this count only reads the ONE expense linked via purchase_cost. A live example (KS66OWP) turned out to have a second, separate expense from an entirely different supplier ("Mr Craig Gill") that also carried a real Delivery Charge, invisible to this query. So the true completeness picture is likely better than these numbers suggest for however many cars have fees recorded under a different, unlinked expense; a full recount would need to sum every expense per vehicle, not just the purchase_cost one, not done.
    - A REAL bug, not just missing data: 6 cars looked like duplicate line items at first glance (two rows sharing the same visible text) but the true expense_category_id on each item told a different story. In 5 of the 6 (EG17FHP, KS66OWP, T99RHM, DL68SBY, and one item on LS16DKK) the item was correctly categorised (Buyers Premium or Assurance / Indemnity Fee) but its DESCRIPTION text still showed the vehicle's own name (or, on LS16DKK, the words "Delivery Charge" on what was actually the Buyers Premium row) rather than the real fee name, a labelling bug in whatever process creates these, not a duplicate entry. Fixed all 5 live via DealerKit's own Edit Vehicle > Expenses > (row menu) > Edit > Item Description, correcting only the text, never touching category, amount or VAT tariff, verified after each save by reading the real record back through the API. The 6th, MT67VSL, is a genuine duplicate (two items both correctly categorised as Delivery Charge, £319.00 and £201.25) that could not be resolved: it was purchased in June 2026, before Mark's own Motorway account existed (the project handed over from Steven on 2026-08-19), so it is not visible in the Motorway account BidBrain can read, and DealerKit's own item timestamps (the two entries one second apart vs six minutes apart) were suggestive but not conclusive about which is correct. Left both in place, unresolved, flagged for Mark's own memory or an old invoice rather than guessed.
**v2.3 starts here, 2026-08-21 (after the v2.2 tag was cut).**

  - FIVE EARLIER 2026-08-21 CHANGES, retroactively logged here 2026-08-23 when cutting the v2.3 release (built and committed the same day as the DealerKit push work below, but never written up at the time):
    - Dealer Auction reader fixes: cards sometimes picked up a network badge icon (a Manheim logo, an inspection badge) instead of the real vehicle photo, since some cards render the badge before the gallery image in the raw markup; fixed to check each `<img>` tag's own `alt="Primary vehicle image"` rather than assuming the first photo-shaped src is the right one (7 of 36 real cards were wrong before the fix, 35 of 36 correct after, the one miss genuinely has no photo at all). Separately, the section heading's Dealer Auction wordmark had no white variant, so the dark "View on" button silently fell back to plain text; a white recolour (assets/da_logo_white.svg/.datauri) fixed it, matching Motorway and Carwow's own white logos on that button.
    - Settings split into an Overview tab (rules shared across every auction house: pricing formula, banned makes/models/vans, value caps, small city cars, banned engines read only) plus a tab per platform (currently just Hard gate), replacing the old platform toggle that lived buried inside the Hard gate card alone. Chosen over genuinely separate URLs, the same tabbed pattern the cockpit itself already uses. One shared save button and API call, unchanged. Overview also gained a reference only dealer_postcode field (Mark: "the sites already know our location, just make this visible"), documenting what should be set on each platform's own account without feeding into any gate check itself, since Carwow computes real distance from its own account's postcode and Motorway trusts its own saved search filter entirely, neither is something a BidBrain setting could override.
    - Service history document photos now captured for every purchased car (motorway.read_service_history_photos, matched on Motorway's own dedicated CDN host, motorway-service-history-photos.imgix.net, entirely separate from the seller's V5/licence host and the plain exterior photo host), stored in purchases.service_history_photos, same fill once never overwrite convention as the rest of that field group. One real bug caught before shipping: this CDN needs its own signed query string kept exactly as read, a freshly built one (matching the working exterior photo pattern) came back 403 Forbidden.
    - The full listing photo gallery (not just the single card photo) is now captured for the cockpit's shortlist cards (Motorway, Carwow, Auction4Cars, each via its own already visited detail page, no extra load; Dealer Auction has no per car detail visit at all, out of scope) and for the purchases page (motorway.read_purchase_gallery, a genuine extra page visit to the car's own original listing, degrading to just the one known photo once that listing eventually comes off the platform). A quiet "N photos" badge opens a shared lightbox modal (prev/next, thumbnail strip, Escape to close), reused unchanged between the two pages. The purchases page's Service history thumbnails open the same modal too, landing on the exact document clicked.
  - PUSH PURCHASES TO DEALERKIT WIRED INTO THE RUN MODAL, 2026-08-22 (Mark: "this should also be part of the run button to trigger this function to pull the latest purchases into DK"). Previously only reachable one reg at a time via python3 purchases_run.py --push-dealerkit REG; now also a real on demand pass matching the existing Glass's checks / DealerKit stock buttons exactly.
    - daily_run.dealerkit_purchases_pass() (new, python3 daily_run.py --dealerkit-purchases): finds every purchase in db.list_purchases() that is not already marked pushed and is past the very first "Awaiting confirmation" status (the original 2026-08-21 scope, "once its reviewing docs status"), opens ONE continuous headed DealerKit window (the same lesson glass_pass learned the hard way earlier in the project, a fresh session per car reads as a logout), waits up to 300s for Mark to log in if needed, then calls dealerkit_write.push_purchase for each pending purchase in turn, marking db.dealerkit_pushed_at on success so a later run never re pushes the same car. One failed car is printed and skipped, does not stop the batch. Progress phase reuses "dealerkit-login" then "valuing", already in the cockpit's ACTIVE_PHASES list, no new phase needed.
    - New purchases.dealerkit_pushed_at column (idempotent migrate, db.mark_dealerkit_pushed(reg)), never cleared automatically, deleting a DealerKit record and wanting it pushed again is a rare manual case not this flag's job.
    - serve.py: mirrors the DealerKit stock button's plumbing exactly, a new _dealerkit_purchases_proc global, /api/run-dealerkit-purchases spawning the subprocess (blocked while any other run is in flight, added to _stop_run's kill list), dealerkit_purchases_running exposed on /api/run-status.
    - render.py: a third button in the Run modal's "Other checks" section, "Push purchases to DealerKit" (id dealerkitpurchasesbtn), its own confirm dialog explaining what it does and that Retail Price is left for Mark. setRunning/pollRun/the page load reload detection all extended with a new which value 'dealerkitpurchases' so the right button shows "Pushing..." and disables while it runs, the same pattern already used for 'glass' and 'dealerkit'. No new progress phase needed since dealerkit-login was already tracked.
    - Verified: 256 tests pass, daily_run.py/serve.py/bidbrain.render/bidbrain.db all import cleanly, a real --render-only rebuild produced valid HTML with the new button (grepped for it), the generated inline JS parses cleanly (node --check), and the restarted live com.bidbrain.serve process actually serves the new button and the new dealerkit_purchases_running field on /api/run-status. NOT run against a real batch of purchases yet (nothing was currently pending, every tracked purchase had already been pushed or existed in DealerKit by the time this was built), so the actual live push through this new button specifically is still to be proven end to end next time a genuinely new purchase is pending.
  - DUE IN CARS AUDITED AND FIXED, 2026-08-23 (Mark: "check the due in cars that are on DK and make sure 4 photos are present, purchase price, auction fee, delivery fee and indemity, along with v5 and service history and use bid brain to retail price the car"). Worked through all 8 vehicles at life_cycle_status 5 ("Due In") in the live DealerKit stocklist (fetched directly via its own /api/stocklist API, paginated, filtered client side, not guessed), cross checked against BidBrain's own purchases table.
    - KF66GJZ (id 330): its expense had silently never saved at all in the earlier 2026-08-22 push (a genuine bug, not just missing data, see below), fixed by re entering the full 4 line breakdown (Chassis 6210 N-No VAT, Delivery Charge 267.70, Buyers Premium 329, Assurance/Indemnity Fee 44.99, Team RightDrive Car Finance - Sales, Supplier Motorway), Total 6980.03 matching BidBrain's payment breakdown exactly. Images (4) and documents (2, its real service history) were already correct.
    - BU16WZB (id 331): same silent non save bug, fixed the same way, but only a single Chassis line (4822) since this purchase is still "Reviewing documents" on Motorway's side with no itemised Motorway fee breakdown captured yet. Retail set to 9495 (BidBrain's own retail_estimate). 4 images already present; no service history captured for this reg yet, so none to add.
    - THE SILENT NON SAVE BUG: DealerKit's Add Expense modal requires a Team, and leaving it on the default "Select team" fails the save with a 422 ("The selected team id is invalid.") that the UI does not surface as an error banner, the modal just sits there looking like nothing happened, so a session ending there looks identical to a real save. Caught by checking the real API afterward (purchase_cost was null despite the earlier session's own "Save & Approve" click), not by trusting the UI. Every expense in this session set Team explicitly to "RightDrive Car Finance - Sales" (matching the team_id already used on HG17NRF, the one fully human added reference car). Not yet fixed in bidbrain/dealerkit_write.py's own push_purchase flow (which only ever types the single Purchase Price into the Stocklisting step, never uses the Add Expense modal this bug lives in), flagged here as a real gap: an automated push could hit the exact same silent failure and nobody has checked whether push_purchase's own code path is affected. Worth a dedicated check next time that path runs for real.
    - PK68AXZ (id 323): had a Chassis only expense (from the 2026-08-19 build), added the 3 missing fee lines (Delivery Charge 180.30, Buyers Premium 359, Assurance/Indemnity Fee 44.99), Total 9902.15 matching BidBrain's price exactly. Retail was already set to 13495 in DealerKit versus BidBrain's own retail_estimate of 13795, a real discrepancy, left alone rather than overwritten (an existing manually set figure is Mark's own call, not silently replaced), flagged to him directly. Added its 3 real service history document photos.
    - AY66WFW (id 332): its existing Chassis line (4266, from the 2026-08-21 evening automated push) had the wrong VAT Tariff, S-Standard instead of N-No VAT (every other Chassis line in the account, including the human added HG17NRF, is N), corrected via Edit item. Retail already correct (8495). Added its 2 real service history document photos.
    - DH66KCV (id 333): the one that never got anything automatically despite three separate attempts logged on 2026-08-21 evening (see that entry). Retail set to 8995 (BidBrain's retail_estimate). Full 4 line expense built from scratch (Chassis 5577 N-No VAT, Delivery Charge 212.60, Buyers Premium 329, Assurance/Indemnity Fee 44.99, Total 6280.91, matching BidBrain's price exactly). 4 exterior photos and 5 real service history document photos uploaded via dealerkit_write.upload_images/upload_documents (see below).
    - EY16UJJ (id 324) and YL63BHE (id 327): correctly out of scope, confirmed by reading their real DealerKit expense contact rather than assumed. EY16UJJ is a genuine CarWow purchase (contact "CarWow") that purchases_run.py has never captured a payment breakdown for (that reader is Motorway specific), so BidBrain has nothing to contribute beyond what DealerKit already has (Chassis 6600, retail 10495, both already set). YL63BHE is not an auction purchase at all, its expense contact is an individual with a "PX-131" reference, a part exchange, never something BidBrain tracks.
    - V5: not uploaded anywhere in this pass, consistent with the standing rule (no V5 image has ever been captured for a not yet purchased listing, sourcing one for an already bought car is separate, unstarted work). HG17NRF is the only Due In car with a real V5 attached, and that arrived through DealerKit's own Motorway integration when Mark added it by hand, not through BidBrain.
    - The photo/document upload itself needed the DealerKit AUTOMATION session (data/dealerkit_state.json, separate from whatever is logged in on screen) to be freshly logged in, it had died since 2026-08-21. Mark ran python3 login.py dealerkit manual and said "done" in chat; BidBrain then created the data/.login_done flag file itself (the mechanism login.py's own docstring describes, "a small flag file is created... when Steven says so") since manual mode waits on that flag rather than auto detecting, and the session captured cleanly. dealerkit_write.upload_images/upload_documents (built 2026-08-21, unchanged) then ran cleanly against all 3 cars needing photos or documents, verified afterward against the live API (n_images/n_docs per car), not just the script's own reported counts.
    - Also investigated and deliberately NOT used: DealerKit's Images upload widget has an "in-tray" option alongside the native file picker, a pool of already uploaded files. Opened it to check whether it might be a faster path, found photos and document scans that did not look related to the cars in this pass (a different hatchback, unlabelled paperwork), so left it untouched rather than risk attaching the wrong file to the wrong vehicle, a shared in-tray with no per car labelling is not a reliable source for this kind of accuracy sensitive work.
  - V5 ADDED TO THE AUTOMATION, 2026-08-23, same session (Mark, after being told why it had stayed manual: "ok lets add the v5 to the automation"). Checked live first rather than assumed a path existed: the same purchase summary page read_purchase_breakdown already opens (/purchase-summary/<vehicle_ref>) carries the seller's real V5C scan(s) on mw-documents.imgix.net, filename containing "/v5-", entirely separate from "/driving_licence-" on the exact same host (that stays uncaptured everywhere in this project, never becomes a business need the way a V5 does once the car is actually bought). New motorway.read_v5_photos(page), same shape and same crop dedupe logic as the existing read_service_history_photos, matched only on the "/v5-" filename fragment so it can never pick up the licence scan even with a host only match. Wired into purchases_run.py's existing Motorway payment breakdown loop (same page visit, no extra cost), a new purchases.v5_photos column (JSON list, added to _PURCHASE_OWN_FIELDS, same fill once never overwrite convention as service_history_photos and photo_urls). dealerkit_write.push_purchase now uploads v5_photos together with service_history_photos as one combined Documents upload. 6 new tests (the reader's dedupe and driving-licence exclusion, a full db round trip proving v5_photos fills once and never gets overwritten), 262 pass total.
    - Backfilled live for every Motorway purchase with no v5_photos yet (25 rows): 18 had a real V5 scan already sitting on Motorway's own page, ready to use, a genuinely good hit rate this project did not know about until asked to check. BU16WZB (still "Reviewing documents" on Motorway's side, the same status that blocks its payment breakdown too) correctly came back empty rather than guessed, Motorway itself has not reviewed a V5 for that car yet.
    - Pushed the newly captured V5 scans into DealerKit for the 4 Due In cars from the audit above that did not already have one: KF66GJZ (2→4 docs), PK68AXZ (3→5 docs), AY66WFW (2→4 docs), DH66KCV (5→7 docs), all verified against the live API afterward. HG17NRF was deliberately left alone, it already carries its own real V5 (added when Mark added that car by hand, DealerKit's own Motorway integration pulled it in), uploading BidBrain's independently captured copy on top would have just duplicated it.
