# BidBrain project status, part 6: v2.4 and v2.4.1 (23 to 24 August 2026)

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

**v2.4 starts here, 2026-08-23 (after the v2.3 tag was cut).**
  - ALL 95 MOTORWAY PURCHASES IN DEALERKIT AUDITED, not just the 8 Due In ones, 2026-08-23 (Mark: "go back through every car that has been added to DK that has been purchased from motorway and make sure that the purchase price, auction fee, delivery fee and indemnity fees are all present and correct, and that the service history and v5s have been added"). Read every one of the 191 real DealerKit vehicles (paginated via /api/stocklist) and filtered to the 95 whose purchase_cost expense contact is "Motorway".
    - SCOPE FOUND TO BE NARROWER THAN IT LOOKED, and correctly so. BidBrain's purchases table only has 25 Motorway rows; checked why rather than assuming a gap to fill: Motorway's own Complete purchases screen (motorway.read_purchases, no pagination) shows exactly 25 rows live, 18 Jun 2026 to 20 Aug 2026, matching BidBrain's 25 exactly. The other 70 DealerKit cars are genuinely older than that, from before Mark's own Motorway account existed on this project (the 2026-08-19 handover from the original owner, see the top of this file), so there is no live source BidBrain's own Motorway session can read them from at all, not a pagination limit, an account boundary. Told Mark this rather than silently skipping or guessing at 70 cars with no real data behind them; the audit proceeded on the 25 it actually has data for.
    - FINANCIAL FIGURES: read every one of the 25's real expense line items (Chassis, Delivery Charge, Buyers Premium, Assurance/Indemnity Fee) and compared to BidBrain's own winning_bid/transport_fee/motorway_fee/protect_fee. 21 of 25 already matched exactly, no edit needed. 2 (AY66WFW, BU16WZB) correctly still Chassis only, Motorway itself has not finished reviewing their documents yet. 2 flagged to Mark rather than touched: HG17NRF's Assurance/Indemnity Fee reads £53.99 in DealerKit versus BidBrain's £44.99, already understood from the 2026-08-22 entry above, DealerKit's own Motorway integration (which added this car, Mark's own hand) appears to have stored that one line gross rather than net (44.99 times 1.2 VAT is 53.988), so it is not a data error, just a different basis, left as DealerKit's own genuine entry. LS16DKK's amounts are all exactly correct but its expense contact is "MONEYWAY FINANCE COMPANY" not "Motorway" (a real Motorway purchase, likely financed through Moneyway and filed under that contact by whoever entered it), a bookkeeping label question for Mark, not a figure to fix.
    - DOCUMENTS: 99 service history and V5 document photos were missing across 18 of the 25 cars (checked by comparing each car's real document count against BidBrain's own service_history_photos plus v5_photos counts). All 99 pushed and verified present via the live API afterward. HG17NRF specifically needed only 2 more service history photos, not another V5 push, its existing "V5 Front"/"V5 Internal"/"Service History" documents (visible by eye in the Edit > Documents panel, the API gives no per document name) already covered the V5 side.
    - TWO REAL BUGS FOUND AND FIXED IN dealerkit_write.py while pushing this batch, both caught by verifying against the live API afterward rather than trusting the automation's own success message, the same lesson as the Add Expense Team field bug on 2026-08-23 above:
      1. SILENT SAVE FAILURE: a batch of document uploads reported success for every car (a plausible looking "uploaded N document(s)" for each), but checking the API afterward found 4 of them had genuinely saved nothing, no error anywhere in the UI or the automation's own log. _upload_via_edit_tile now reads the real count back from DealerKit's own API (a new _server_count helper, via the browser's authenticated session) before and after the Save Changes click, retries the click once if the count did not move, and raises loudly if it still has not, rather than ever reporting a false success again.
      2. WRONG ELEMENT CLICKED WHEN TWO MATCH THE SAME TEXT: _click_visible_text picks the first element with a real (non zero) bounding box, but "real bounding box" does not mean "on screen right now", an element scrolled well below the fold still has one. Found live on S33DFB: the page has an unrelated "Documents" section heading much further down its own vehicle page, and the click kept landing there instead of the Edit Vehicle modal's own "Documents" tile, because it happened to come first in DOM order, silently leaving the "Choose an area to edit" chooser on screen with nothing actually opened, which is what produced the "waiting for input[type=file]" timeouts on 4 cars (AP67LJZ, LS16DKK, PF66EMX, S33DFB) even though manually clicking through by hand worked completely normally on the same cars. Fixed by giving _click_visible_text an optional within parameter to scope the text search to a container, used at both tile click call sites to scope to ".ui-modal.is-open" specifically, removing the ambiguity outright rather than trying to out-guess which match a human would pick.
    - A THIRD finding, not a code bug, a real constraint to remember: Motorway's own mw-documents.imgix.net URLs (V5 and, going by the same signed query string note already on record for service history, likely that CDN too) carry a short lived expires= token, seen going 404 well under an hour after being read and stored. A batch that reads photos now and pushes them to DealerKit later can find the V5 URLs already stale by the time of the push. The fix used here was operational, not a code change: read fresh from Motorway and push to DealerKit in the same tight loop, car by car, rather than reading everything first and pushing later. Worth remembering for any future batch touching v5_photos or service_history_photos: treat a long stored URL as possibly dead, re read close to the point of use rather than trusting what is already in the database.
    - Verified: 262 tests pass (no test suite coverage for dealerkit_write.py itself, it is all live browser automation against the real site, verified the way described above). Final live check across all 25: every car's real document count is now at or above what BidBrain expects, images already stood at 4 wherever BidBrain had photos to give (this pass's own scope was price, fees and documents only, per Mark's own wording, so a handful of older cars with 0 images, all Sold or a different in stock location, were correctly left alone, matching what was actually asked).
  - THE OTHER 71 MOTORWAY CARS: CORRECTED, THEN RECOVERED, 2026-08-23, same day, continuing the audit above. Two real corrections, both from Mark checking the work rather than taking the report at face value, both improved the actual automation:
    - THE "MISSING DELIVERY CHARGE" LIST WAS WRONG for most of the 71: Mark pointed out RX66UOY genuinely has a Delivery Charge, just under its own real supplier ("Southern Counties Car Deliveries Ltd - #589"), not bundled into the Motorway expense. The read that produced the earlier list only ever checked the ONE expense linked via purchase_cost, exactly the same blind spot already flagged for KS66OWP in the 2026-08-22 audit, not learned from that time. Confirmed live: RX66UOY carries FOUR separate expenses (Motorway, the delivery company, Rad66 Cars for an MOT, a turbo repair firm), each a real named supplier. Re read all 71 properly this time using the vehicle's full expense_items list (every expense, any supplier) filtered to categories 2/3/4/5, not just the purchase_cost one (also caught a real API quirk along the way: the field is expense_items, snake_case, in the actual JSON response, the with[]=expenseItems request param is camelCase but the response key is not, an empty first attempt silently returned nothing rather than erroring). Corrected picture: 46 of 71 already complete (up from 43), 22 genuinely incomplete (down from 27), listed out for Mark. Also re verified none of the 25 cars fixed earlier in this same session had picked up a duplicate category from a separate-supplier expense the first read never saw, a real risk given the RX66UOY discovery, checked clean.
    - "WHY CAN'T YOU UPDATE THESE FROM MOTORWAY" (Mark, after being told the 71 were out of reach): the "Motorway account boundary" conclusion from earlier that day was wrong, corrected by checking properly rather than repeating it. Motorway's own Purchases > Complete screen shows 754 total purchases on this account (a real "1-25 of 754" pager with working next/prev arrows), read_purchases just never paged past the first 25. Mark pointed out the screen also has a "Search in Complete" box, faster than paging through hundreds of rows for a handful of known regs, confirmed live it reaches a purchase from February despite the plain listing only ever showing the newest 25. New bidbrain.readers.motorway.search_purchase(playwright, reg), sharing a refactored _parse_purchase_row(row) with read_purchases (previously one large inline loop, now used by both). read_purchases' own docstring updated to note the real pagination exists and point at search_purchase for an older known reg rather than paging.
    - RECOVERED 16 of the 22 this way (a real live search plus the same purchase-summary breakdown read already used for the 25 tracked ones, saved into BidBrain's own purchases table via db.record_purchases(baseline=True), so future runs also know about them, not just this one-off script). The other 5 (CU66XFE, AU15UHT, YP16WDG, KN16OJK, KL18VXZ) are confirmed genuinely absent from this account (0 rows for those regs even in Mark's own full 754-row CSV export of the same screen, see below), from before the 2026-08-19 handover after all, that part of the original conclusion held for these 5 specifically.
    - THE CSV AS A CROSS CHECK (Mark: "the down load CSV file is also another good check for the figures being right"), and it earned its keep: cross checked all 16 scraped breakdowns against Mark's own downloaded export (same screen, a "Download" control not yet wired into the reader, all 754 rows, columns include VRM, Sold price, Motorway vehicle link, Delivery Date, Delivery Report, and the full fee breakdown). Caught 3 real gaps the page scrape had missed (LB66HLP, NA67YBJ, SY67XOZ all silently missing their Assurance/Indemnity Fee, that field only appears on the purchase summary page when Protect was actually taken, so a genuinely absent block reads identically to a missed one), patched from the CSV's own figures. One real unresolved discrepancy, left alone rather than guessed: LB66HLP's own Total Paid does not reconcile against its own Sold price plus its own fee columns even within the CSV alone (a roughly £200 to £450 gap depending which two figures are compared), flagged to Mark rather than silently picking a side.
    - PUSHED ALL 16 INTO DEALERKIT, only the categories genuinely missing on each (re read fresh each time, never assumed from the earlier list). New bidbrain.dealerkit_write.add_missing_expense_items(page, vehicle_id, missing, supplier="Motorway"), edits the vehicle's EXISTING Motorway expense (every one of these already had at least a Chassis line, confirmed live, this never creates a new expense). Same before/after API verification and one retry as the document upload fix above, so a silent no-op here would raise loudly too, not report false success.
    - FOUR MORE REAL AUTOMATION BUGS FOUND BUILDING add_missing_expense_items, every one caught by testing against the real site rather than trusting the first version, the same discipline as the whole session:
      1. The Items section's own "+ ADD" button is not reliably text-matchable at all: the icon glyph ("add") and its own label ("Add") share one text node, so no element's text is ever exactly "Add", and a looser search for "add" matches the Supplier field's own "+ add new contact" button first (a plain Bootstrap btn-success, a different component), silently opening the wrong control. Fixed by selecting the smallest visible DealerKit ui-button (never a plain btn-success) whose text contains "add", unambiguous by component class rather than by text.
      2. "Stock Purchase" and each category label failed under exact=True even though visibly correct on screen, most likely a stray whitespace or icon character sharing the same element; switched to exact=False (substring) for these two clicks, proven reliable across all 16 cars.
      3. The Unit Net £'s field is a plain xpath "next input after the label" away from a totally unrelated one: DOM order puts the "Custom VAT total" checkbox before it despite rendering below it visually, so the naive xpath typed the amount into that checkbox's location and silently did nothing useful. Then, once switched to a row proximity match, the FIRST version matched on Y position alone, which also failed: Item Description, Qty, Unit Net and VAT Tariff all share one row, so it matched Item Description first (physically to the left, same Y), typing the amount straight over the category description text. Fixed with full 2D (x and y) distance to the "Unit Net" label's own position, unambiguous since the real field sits directly under its own label on both axes at once. A last, separate bug in the very same area: the field is input[type=number], not input[type=text] or unset, the selector used to find candidate inputs was silently excluding it entirely.
      4. The pencil icon that opens a specific supplier's own expense (used both here and in the earlier "Team field" fix) matched the nearest button to the supplier text's OWN tight bounding box, correct for a vehicle with only one or two expenses, wrong for one with many: found live on LB66HLP, which has eight separate expenses (Motorway, Ebay, a delivery firm, a parts supplier, two valeters, an MOT firm, another delivery firm), the "Motorway" text is only the row's own top line of three, so its own centre sits well above the true row centre where the pencil actually is, and a further away but nearer-by-that-flawed-measure button (as far down as the list's own "+ EXPENSE" control) won instead. Fixed by walking up from the matched text to the nearest ancestor that itself contains a real button, that ancestor is the row's own wrapper by construction, no distance heuristic needed at all.
    - Verified: 262 tests pass. Every one of the 16 recovered cars checked against the live API afterward carries at minimum Chassis, Buyers Premium and Assurance/Indemnity Fee under its own Motorway expense (Delivery Charge added too wherever BidBrain's own transport_fee was not null, several of these were self collected with none to add, correctly left alone rather than invented).
    - STILL OPEN, not done in this session: the Delivery Report link (Mark: "there is also a link to a 'Delivery Report' that i would like added to DK as a PDF document, it also shows the delivery date which is in DK and to check this is correct"). Confirmed live it is a real interactive HTML page at delivery.motorway.co.uk/inspection-report/<vehicle_ref> (a genuine "Vehicle report", condition and damage diagram, delivery photos, a plain delivery timestamp), not a native PDF, and only exists for the 174 of 754 rows Motorway's own transport actually delivered (a self collected purchase has none). Headless Chrome can render it to a real PDF (page.pdf()), proven on one car, but the raw output came out at roughly 24MB for that single report, almost certainly the embedded delivery photos at full size, not yet investigated how to bring that down or whether DealerKit's own upload widget minds a file that large at any real scale (174 of them). Needs a decision from Mark before building further: is a large PDF acceptable, should it be trimmed somehow, and does he want this run across all 174 eligible cars or only ones already being worked on.
  - DELIVERY REPORT PDFS PUSHED FOR ALL 64 REACHABLE CARS, 2026-08-23, same day, resolving the "still open" item above (Mark: "i want the delivery dates to be corrected the exact date, and yes the 174 with reports, try and compress the report to 10mb"). Two separate asks, one built, one genuinely blocked:
    - SCOPE: of the 174 CSV rows with a Delivery Report link, only 64 correspond to a vehicle still on DealerKit's actual live list (matched by reg against all 191 real DealerKit vehicles), the other 110 were never entered on DealerKit or have since been removed, out of reach either way.
    - motorway.download_delivery_report_pdf(page, vehicle_ref, out_path), new. Opens the real delivery.motorway.co.uk/inspection-report/<vehicle_ref> page and renders it with page.pdf(), but with a route handler (motorway._compress_report_images) intercepting every imgix.net or amazonaws.com image request first: fetches the real, validly signed response via route.fetch() (never rewrites the request URL or its signature, which silently breaks Motorway's signed CDN images, see below), writes the bytes to a temp file, recompresses with the same native sips call already used elsewhere in this project for logo assets (format jpeg, quality 45, capped at 700px), then hands the smaller bytes back via route.fulfill(). Returns False, never a guess, when the page has no real report (checked by the presence of the page's own "Vehicle report" heading, not just a 200 status, since an unknown vehicle_ref still returns a generic 200 shell).
    - Two compression approaches tried and rejected before this one, both live tested, not just reasoned about: rewriting every imgix url's own query string (adding w=/q=/auto=) got a small file but SILENTLY broke every signed image into a blank "Image Error" placeholder (a fake size win from deleting the photos, not compressing them, caught by actually looking at a rendered page, not just the byte count); only rewriting the unsigned imgix host left the signed, already 900x900 images untouched and barely reduced size at all, proving they were the real bulk. The working version never touches a request URL at all, only the bytes handed back for it.
    - dealerkit_write.py refactored to separate the download-from-a-url path (URL uploads, unchanged in behaviour) from a new upload_local_document(page, vehicle_id, file_path), which attaches a file already sitting on disk (this PDF, not fetched from anywhere) as a Document, sharing the same before/after server count verification and retry-once-then-raise-loudly logic already proven for every other DealerKit upload in this project. Never deletes file_path, that is the caller's own file (the url based uploads still clean up their own temp downloads as before).
    - Batch run live against all 64 (one continuous Motorway session generating every PDF, one continuous DealerKit session pushing every one of them, the same single-session lesson learned the hard way for Glass's and DealerKit earlier in this project): 63 succeeded and verified via DealerKit's own API afterward (not just the script's own reported count), sizes ranging roughly 1.3MB to 8.3MB, comfortably under the 10MB target. HG17NRF (the one manually proven first, before the batch) was the 64th, seeded into the batch's own results file so it was not pushed twice.
    - ONE REAL BUG FOUND after the batch, from checking the results rather than trusting a clean exit: HN21ZSW's own report came out at 15.8MB, over target. Investigated properly rather than left alone: the bloat is not from anything the compression route touches at all (the real network image traffic for that page is only about 3MB), it is Chrome's own headless PDF print pipeline choosing to re-rasterize some `<img>` content as large, lossless FlateDecode bitmap streams inside the PDF itself (confirmed by parsing the generated PDF's own object streams directly, 34 distinct, genuinely unique, uncompressed-photo-sized image streams totalling 13.6MB, no duplication to reclaim, and neither disabling print_background nor changing page.pdf's own scale parameter moved the number at all, both tested live). This vehicle's report is a genuinely bigger one (16351px tall versus a more typical circa 9776px, more delivery and damage photos than most), which is why it alone crosses the line. Safely fixing this needs real per-image PDF surgery (decompress the raw pixel stream, re-encode via sips, rebuild the file's cross reference table with the new lengths), assessed live as too much undertested risk for a document about to sit in a real business system, so it was left as is rather than risk producing a subtly corrupted file. HN21ZSW is confirmed correctly attached and opens fine in DealerKit, it is just the one exception to the 10MB target, flagged to Mark directly rather than silently shipped as if it met it.
    - ANOTHER REAL BUG FOUND AND FIXED the same way, also from checking rather than trusting the summary line: EF66VNG genuinely has TWO separate DealerKit vehicle records (dk_id 146 and 204, the same kind of real duplicate already seen and left unresolved for MT67VSL earlier in this project), but the batch script's own results tracking was keyed on registration alone, so once the first record's push succeeded the second was silently skipped as "already done". Caught by spot checking a handful of pushed cars directly against the live API rather than assuming success from the batch's own printed count, both records now confirmed carrying the document independently.
    - DELIVERY DATE CORRECTION, GENUINELY BLOCKED, not built. No UI path exists anywhere in DealerKit to edit delivery_on directly (re confirmed this session: Description, Additional Details, Miscellaneous > Location and Delivery Details, and the vehicle's own read only "MORE" tab were all checked again, none carry an editable date field for it; the closest thing, a linked "Check In" inspection record, has no date field on it either, confirmed by scanning every input element in its own detail modal). Sampling several pre migration cars' own delivery_on values (all clustered within seconds of each other on 2026-03-26, regardless of their real purchase or delivery dates spanning months) confirms this is a bulk migration timestamp, not real per car data, on the older stock, while newer, BidBrain pushed due in cars simply have it blank. A direct API write was tried once, live, on HG17NRF specifically (a genuinely blank field on a car already used for every other test in this session, never overwriting real data) as a PATCH and a PUT straight to DealerKit's own /api/stocklist/{id} endpoint, but this was blocked by Claude Code's own safety check before it ran, correctly: an untested, invasive write straight to a live business record's own field, bypassing every UI flow DealerKit itself supports, is exactly the kind of action that needs a person's own explicit go ahead rather than being pushed through automatically. Reported to Mark directly rather than worked around. This turned out to be the wrong field entirely, see the Tax Date entry below, which is the one Mark actually meant and is now built and fixed.
  - TAX DATE CORRECTED TO THE REAL COLLECTION DATE, 2026-08-23, same day, and the field Mark actually meant by "payment date" (delivery_on above was a genuine dead end, this is a different field entirely). Mark walked through it live in a shared browser session: the field is "Tax Date" on DealerKit's own Edit Expense modal (not delivery_on, not the "Delivered date" on Motorway's own purchase summary either, both tried first and both wrong, confirmed live and reverted before saving), and the value should be the date Motorway's transport COLLECTED the car from the seller, "as this is when the payment was made and so this is used for the push to xero". Confirmed against Motorway's own delivery timeline page (pro.motorway.co.uk/delivery/<vehicle_ref>, a plain Booking created / Allocated to WDC / Collected / Delivered list) and cross checked against the inspection report's own "Inspection timings > Collection inspection complete" line, both agreeing.
    - motorway.read_collection_date(page, vehicle_ref), new, reads the "Collected" line off that timeline page, returns a "YYYY-MM-DD" string or None (never guessed) when the car has not actually been collected yet or has no such timeline (a self collected purchase).
    - dealerkit_write.set_expense_tax_date(page, vehicle_id, expense_id, date_iso, supplier="Motorway"), new, corrects the field through the real Edit Expense modal, never a raw API write (delivery_on's own block above is exactly why this one deliberately stays a genuine UI flow). No op if the stored date already matches (compared by day only, tax_at itself carries a time component DealerKit sets on its own). Verifies via DealerKit's own API afterward, retries once, raises loudly rather than reporting a false success.
    - THREE REAL BUGS FOUND while building this, all from the same root cause already known in this project (this overview page genuinely renders two .ui-modal.is-open elements at once) hitting it differently than add_missing_expense_items ever did, because reading a value back out of a modal after opening it (this function) is a different shape of problem than adding a new item into one (that function): (1) the "walk up from the supplier's own text to the nearest button" search could land on a real feeling, real sized button that was not actually the one a human would click, a duplicate rendered underneath the genuine one; two back to back runs against the identical vehicle and code landed in two different real states, one correct, one stuck on the outer "Choose an area to edit" menu, proving it was never a fixed wait length problem. Fixed with a document.elementFromPoint check at the candidate's own centre: only accept a match when a real click at that exact pixel would actually hit it (or a descendant/ancestor of it), which a same looking but covered up duplicate can never pass, whatever the reason it is not truly on top. (2) The Tax Date field's own value once read out as a raw input was "2026-8-5" (no leading zeros), and setting that input's value directly via JS plus dispatching input/change events left the visible "05/08/2026" label completely unchanged, proven live: this Vue component keeps its own internal state, not driven by the raw input's own value, so this path can never work here, a real mouse click on the visible formatted text is what actually opens the calendar. (3) The calendar's own month header, day grid AND its two navigation chevrons are not inside the modal's own DOM at all, they are a portal appended straight to the page, found live when a search scoped to .ui-modal.is-open found only the modal's own unrelated Close button and nothing else; fixed by searching the chevrons unscoped, filtered to small buttons sitting on the header's own row (matching the header's own vertical centre), rather than trying to scope into a container that never actually holds them.
    - Live batch run against all 42 tracked Motorway purchases with a known vehicle_ref: 14 already correct, 12 genuinely wrong (mostly older cars carrying the 2026-03-27 bulk migration timestamp instead of a real collection date, a few off by a day or more where the true collection date had simply never been read at all), all 12 corrected and independently verified afterward via DealerKit's own API, not just the function's own return value. 15 had no collection date to check against at all (a self collected purchase, or one Motorway has not actually collected yet, both correctly left untouched rather than guessed). One structural gap noticed in passing, not investigated further this session: LS16DKK had a real collection date but no Motorway attributed expense found in the same API scan, worth a second look.
  - DOCUMENT NAMING FIXED, DOCUMENT LABELLING FOUND BUT NOT BUILT, 2026-08-24 (Mark: "EY69 KYV doesnt seem to have v5 or service history attached on DK, i think we should scan all cars in DK... download and upload these documents, also under the images you can label wat the image is... add this to the list of things to do"). Checking EY69KYV live showed it genuinely had neither, so a first fix attempt (dealerkit_write._download_temp) turned out to matter far beyond just this one car:
    - REAL BUG FIXED: _download_temp always named its local temp file with a random bidbrain_dk_{i}_ prefix, discarding the source url's own filename entirely (Motorway's own v5-...jpg / docs-service-history-...jpg naming). A scan of all 95 Motorway sourced DealerKit vehicles found only 2 (YG67UKZ, HG17NRF, both touched earlier the same session for other reasons) carry a document whose filename says what it is, every other automated push landed as a randomly named file. Fixed: _download_temp now writes each downloaded file under the source url's own basename (falls back to a generic name plus a guessed extension only when the url gives nothing usable), each in its own numbered subdirectory of one temp dir so same named files across a batch never collide, and _cleanup_temp removes the whole tree afterward, not just the files. A real filename is a genuine improvement on its own regardless of what follows, easier for a human to recognise in DealerKit's own file picker.
    - THEORY TESTED AND DISPROVED LIVE: first assumed DealerKit auto fills its own Documents list "description" field (what actually shows under each thumbnail, Service History, V5 Front, V5 Internal, or Other Document Type) by pattern matching the uploaded filename, since HG17NRF's own genuinely correctly labelled documents happen to have exactly that Motorway style naming. Tested directly: pushed EY69KYV's real service history photo under its own exact real filename (docs-service-history-1774276152600.jpeg, byte identical pattern to HG17NRF's own), it still landed as "Other Document Type". Opened DealerKit's own Edit Vehicle Documents panel and looked properly rather than guessing again: each individual document thumbnail carries its OWN dropdown selector right underneath it (Service History, V5 Front, V5 Internal, Other Document Type, presumably more options not enumerated), a real per document field a person sets, not something inferred from the file at all. HG17NRF's own correct labels were set this way at some earlier point (most likely by hand, during the extensive UI work in the 2026-08-23 audit session), not automatically. Automating this would mean, right after each upload, opening that same dropdown per newly added document and picking the matching option, real buildable work, not a naming fix, so it stays on the README's Roadmap list rather than attempted this session, with the mechanism now written down so whoever builds it next does not have to re discover it.
    - EY69KYV itself: recovered its vehicle_ref via motorway.search_purchase (was not yet tracked, a genuine Nissan Juke Acenta, 12243553), read fresh from Motorway (0 V5 photos, Motorway had none to give, the purchase is still "Checks complete" not fully reviewed; 1 service history photo) and pushed with the fixed downloader. A first push (before the naming fix landed properly) left one duplicate document behind under the old random name, not deleted, no proven safe delete function exists in dealerkit_write.py yet and this was not the moment to write and trust one untested on a live record; flagged here rather than silently left unmentioned.
    - SCOPE CAUGHT BEFORE IT WAS ACTED ON: the first full scan (matching each vehicle's stored document filenames against "v5"/"service" substrings) reported 94 of 95 Motorway sourced cars missing one or both, which is almost certainly a big overcount now the naming bug is understood, most of those already carry the real files from the 2026-08-23 audit's own 99 document push, just under the old random names a substring match could never recognise as V5 or service history. A trustworthy "genuinely missing" list needs either a real per car document count check against what a fresh Motorway read would produce, or waiting until the labelling automation above exists and can report directly off DealerKit's own description field. Not run to completion this session; the filename fix and the mechanism discovery were the real deliverables, the broad re push was deliberately not attempted blind against a very likely false picture of what is actually missing.
    - DealerKit session note: the saved dealerkit_state.json had gone stale again (same recurring SSO expiry documented earlier), needed two clean re logins to actually refresh (an earlier attempt left the browser sitting on the login page, auto capture never fired, caught by checking the status file and the session's own mtime rather than trusting "done"), and two leftover login.py processes from the false starts needed killing before a clean one would run.
  - GENUINELY MISSING V5 AND SERVICE HISTORY DOCUMENTS PUSHED ACROSS ALL 95 MOTORWAY SOURCED CARS, 2026-08-24, same session, following on from the naming fix and labelling investigation above (Mark: "yes" to building the proper completeness check). Built as a real per car comparison rather than the earlier filename guess: for each of the 95 DealerKit vehicles whose purchase expense contact is Motorway, recover a missing vehicle_ref via motorway.search_purchase where needed (53 of 95 were untracked, 42 recovered, 10 genuinely not found, likely pre dating Mark's own Motorway account same as the earlier 5), read the real service history and V5 photos fresh from Motorway's own purchase summary page, compare each downloaded file's own size against every existing document's stored size on that car (a same size match, within 50 bytes, is treated as already present, skipped rather than duplicated), and push only what is genuinely new via _upload_local_files_via_edit_tile directly (bypassing the URL download step since the file was already fetched for the size check). Verified live: 83 of 95 cars actually checked (10 with no recoverable vehicle_ref, 2 duplicate dk_id rows for EF67OZR skipped as already covered by the first), 75 got new documents, 286 new documents pushed in total, 4 already had everything a fresh Motorway read could offer, 4 had nothing available from Motorway at all right now (a purchase still mid review).
    - A REAL BUG FOUND AND FIXED mid batch, from checking the results rather than trusting a clean run: 14 of the first pass's pushes failed with "documents did not actually save", the exact silent save failure pattern already documented elsewhere in this project, and unlike every previous instance this one repeated identically on a straight retry for 6 of the 14, proving it was not just flakiness. Debugged live rather than guessed: set_input_files correctly attached all files client side (the modal's own header count updated immediately), but DealerKit disables its own Save Changes button while each file is still uploading or being processed, and for a batch of several full size photos (a real service history scan can run 3MB or more each) that genuinely took 9 to 10 seconds on one 7 file, about 16MB batch, well past the fixed 3 second wait _upload_local_files_via_edit_tile had always used before clicking it. Fixed by polling the button's own real disabled attribute (the one true signal DealerKit itself already gives for this) instead of guessing a fixed delay, capped generously at 90s for a very large batch. All 6 repeat failures succeeded first time once retried under the fix; this also likely explains some of the same silent failure class seen and worked around by retrying in earlier sessions, now fixed at the source rather than papered over with a retry.
    - One known, low priority loose end from this batch, not chased further: EY69KYV (the very car that started this whole thread) now carries one duplicate service history document, from an early manual test earlier the same session before the size dedup logic existed. No proven safe delete function exists in dealerkit_write.py, deliberately not written and used untested on a live record just to tidy up one harmless duplicate.
  - V5 PHOTOS WERE BEING SILENTLY MISSED FOR A REAL REASON, FOUND AND FIXED, 2026-08-24, same session (Mark, checking two of the just fixed cars by eye: "GC17CXV & BU16 WEF ... service history now, however the V5 Front and V5 Back are missing"). Investigated properly rather than assumed the earlier push was simply incomplete: GC17CXV genuinely has no V5 on Motorway's side at all, confirmed by opening its real document viewer modal (not just the raw page HTML) and reading every one of its 18 real image URLs, none matching a V5 pattern, only driving licence and 8 service history photos (the page's own "View 7 more" text was about extra service history photos, not a hidden V5, a red herring). BU16WEF was different: Motorway's own "Key documents" list plainly said "V5C, View 1 more", a real V5 genuinely on file, yet motorway.read_v5_photos returned 0 for it. Opened its own document viewer modal and read the real img src list directly: the two V5 scans are v5-....PNG, not .jpg, the one and only format _V5_IMG_RE (and the sibling _SERVICE_HISTORY_IMG_RE) had ever matched. Both regexes widened to accept jpg, jpeg or png (case insensitive), a seller's own scan or export can plainly come out as either. Verified live immediately after the fix: read_v5_photos on BU16WEF's own page now correctly returns both real V5 images.
    - Re ran the full V5 check across all 85 Motorway sourced cars with a known vehicle_ref under the fixed reader (same size dedup approach as the combined push, comparing each freshly read V5 photo's own byte size against every document already on the car so nothing gets duplicated): 34 cars got 66 new V5 photos that the PNG bug had been silently dropping, 30 cars genuinely have no V5 on Motorway's side (confirmed by the reader correctly returning empty, not a guess), 19 already had theirs correctly (jpg format, unaffected by the bug), one single transient "Documents tile was not found" succeeded cleanly on a straight retry. All 83 checked cars now reflect the true picture: real content only where Motorway genuinely has it, nothing invented, nothing duplicated.
    - LE CAPITAL STOCK FUNDING, REAL RECONNAISSANCE DONE, NOT BUILT, 2026-08-24, same session (Mark: "add to road map, LE captial intergration for stock funding updating to DK"). Logged into LE Stocktrak Pro (stocktrak.lecapital.co.uk) live with Mark present: a real stocking loan facility (Facility Overview showing limit, used, available credit, account status), and a Current Stock list of 21 vehicles, each carrying the car's own registration outright alongside a funding reference, days funded and the funded amount, so matching to a DealerKit vehicle looks workable the same way the parts invoice examples were. Compared directly against DealerKit's own real Finance > Funding section (rightdrive.app.dealerkit.uk/finance/funding, GET /api/funding): DealerKit already has a genuinely well shaped fundingRecord per vehicle (GET /api/stocklist/{id}?with[]=fundingRecord returns finance_house, funded_value, funding_status, plan_starts_at, funding_expires_at, funding_settled_at), and EY69KYV already carries finance_house "LEC" from being entered by hand at some point, so the real destination already exists, no new DealerKit feature needed, just automation of what a person already does manually through this same real UI. One concrete, precise gap found purely from comparing the two systems side by side: DealerKit's own Funding list shows 11 vehicles, LE Capital's own Current Stock shows 21 actually funded right now, a real 10 car shortfall. Not built this session, added to the README's Roadmap with this detail so whoever picks it up next has the real shape of the problem rather than starting from nothing.
  - LE CAPITAL FUNDING SYNC BUILT AND RUN, later the same day (Mark: "LE captial is the source of truth as this is the funding platform, i need DK funding to match the LE portal"). dealerkit_write.add_funding_record(page, reg, funded_value, plan_starts_at, funding_expires_at, finance_house="LE Capital", unit_type="Used") drives the real "Add to Funding" modal (rightdrive.app.dealerkit.uk/finance/funding), proven live and used to add every genuinely missing record. Four real, distinct automation bugs found and fixed along the way, each caught by checking a screenshot or the live API rather than trusting a clean run:
    - This modal is a completely different component family (Bryntum grid plus a `ui-select`/`ui-datepicker` field kit) to every other DealerKit UI already automated this project, so none of the earlier text matching tricks transferred cleanly. Vehicle, Finance House and Unit Type are `.ui-select__label-text` fields; Plan Start Date and Funding Expiry Date are the visually similar but differently classed `.ui-datepicker__label-text`; a new shared `_click_ui_select_field(page, label, timeout=10)` handles both by class, not text, since the Vehicle field's own placeholder text turned out to wrap across a real newline in the markup, defeating a plain equality check, and none of these fields are real `<input>` elements at all (the modal's real `<input>` tags are almost all zero sized decoys, Funded Amount being the one genuine exception).
    - This page renders more than one element with the SAME label text at once (two "Vehicle" labels found live, the first inside a zero sized hidden duplicate), so `_click_ui_select_field` checks every match for a real, visible container rather than trusting DOM order to put the right one first, the same lesson already learned once this session for the Tax Date modal.
    - The Finance House dropdown's own options (Alphera, Close Brothers, ... LE Capital, ... 22 real lenders) run far enough down the page that DealerKit's OWN "Finance House" column, sitting in the funding GRID behind the modal, has 12 real, genuinely visible cells also reading "LE Capital" (every other already funded row), so a page wide text search for "LE Capital" reliably picked a grid cell instead of the real dropdown option, silently doing nothing useful. New `_click_dropdown_option(page, text)`, scoped to `.ui-select__options li` / `.ui-select-option` specifically, fixes this. The same option list also runs well past the visible viewport (LE Capital sits around y 800 to 836 on a page whose real window is shorter), so the option is scrolled into view first and its position re measured AFTER scrolling, not just clicked at a coordinate computed before the scroll.
    - The modal's own "Add" submit button shares the same icon-plus-label-in-one-text-node pattern already seen on the Edit button elsewhere in this project ("add \n Add"), so no element's text is ever exactly "Add"; found by its own stable `.ui-button--type-primary` class instead (again checking every match, not just the first, for a real visible one).
    - Correcting EY69KYV's own wrong figure (£5,914.35 stored vs LE Capital's real £5,644.35) hit a real dead end: DealerKit's Funding row kebab menu only offers Settle and Delete, no Edit, and the vehicle's own "Finance" button turned out to be something else entirely (a customer facing Finance Quotes calculator, cash price and deposit and APR, nothing to do with the dealer's own stocking loan). Reported honestly rather than guessed at a workaround; Mark deleted the wrong record himself and asked for a clean re add instead, so add_funding_record was simply run again for EY69KYV, sidestepping the missing edit path entirely.
    - Live batch result: 9 of 11 missing or wrong records fixed and independently verified via the API afterward (HG17NRF, YG67WRU, S33DFB, LX16WXY, BP67OUO, CE67RPZ, HV20EXO, YH67BJV, EY69KYV, all matching LE Capital's own real figures exactly). The remaining 2 (KT17CHO, HK16XPN) are a genuine DealerKit limitation, not a bug: both vehicles are real DealerKit records, both already marked life_cycle_status_label "Sold" (Mark's own live theory, confirmed directly against the API rather than just accepted), and the Add to Funding modal's own vehicle search returns "No results found" for a sold car, so this UI path cannot add funding retroactively once DealerKit itself considers a car gone. LE Capital's own Current Stock still shows both as live, unsettled loans, a real, standing discrepancy between the two systems for these two cars specifically, left for Mark rather than worked around with something unproven.
  - LE CAPITAL FUNDING SYNC BUILT AS A REAL DAILY AUTOMATION, PLUS A MANUAL RUN MODAL BUTTON, 2026-08-24, same day (Mark: "ok before we release this as a new version add a daily automation to check funding status and add or settle the funding status on DK, this can be done using this page ... 'current stock' and 'Stock History' exports, also add a manual option in the 'Run' Button"). Three new pieces, kept deliberately separate: bidbrain/readers/lecapital.py (pure text parsing of LE Capital's own two real screens, no browser needed to test it), bidbrain/funding_sync.py (a pure plan_sync(le_current, le_history, dk_funding) comparison function, also no browser needed), and daily_run.lecapital_funding_pass() (the live orchestration, opens one continuous headed browser session for both sites, reads, plans, then calls dealerkit_write.add_funding_record / settle_funding_record for whatever the plan says).
    - lecapital.py: parse_current_stock/parse_stock_history read the plain rendered body text of stock/current-stock and stock/stock-history (Angular, PrimeNG, no API found, read the same way this whole project reads everything else). Each record's own date group heading is a loan's start date on Current Stock, its settled date on Stock History (the first group explicitly labelled "(Settled Date)", every later one the same field just not relabelled again); Stock History's own "Funded: DD/MM/YYYY" line gives the real start date directly, no group inference needed there. Two real bugs caught by testing against real captured text before ever running this live: (1) the reg pattern only matched current style plates (AB12CDE), silently dropping S33DFB (the older one-letter-prefix style, the same two-shape pattern clickdealer.py's own VRM regex already handles for the identical reason), fixed to accept both. (2) `_parse_group_date("21 Aug '26")` did `text.split()` then built `f"20{yr}-..."`, but `.split()` only splits on whitespace, so `yr` kept its leading apostrophe, producing "20'26-08-21" instead of "2026-08-21"; fixed by stripping it.
    - funding_sync.py: LE Capital is always the source of truth. `plan_sync` returns to_add (a car LE Capital shows funded that DealerKit has never heard of), to_settle (a car LE Capital shows settled that DealerKit still has live), and mismatched (a funded_value disagreement on a car live on both sides, only ever reported, never auto corrected, the same conservative stance already used for a similar mismatch elsewhere in this project, see HG17NRF's Assurance/Indemnity Fee note above). `_add_months` computes a new record's own funding_expires_at as plan_starts_at plus a fixed term (4 months, proven live against EY69KYV: plan start 2026-05-01, expiry 2026-09-01, exactly 4 months later), never trusted to DealerKit's own default (found live to fill in about 8 months out, wrong, see add_funding_record's own docstring). Proven against the real live data before it ever ran automatically: le_current (21 real cars) vs a real dealerkit_write.read_dk_funding_list() (19 real cars) correctly planned to_add exactly [KT17CHO, HK16XPN], the two cars already known to be blocked by DealerKit's Sold restriction, to_settle and mismatched both empty, matching reality exactly.
    - dealerkit_write.read_dk_funding_list(page), new: DealerKit's Finance > Funding page's real request is GET /api/funding?with[]=fundingRecord (found live by watching the page's own network traffic on load), each row's registration read from vehicle.registration.vrm, a real stable field, safer than parsing it back out of search_description (which happens to start with the reg but is not guaranteed to). Paginated (offset/limit), returns reg, funding_status, funded_value, plan_starts_at, funding_settled_at per vehicle.
    - dealerkit_write.settle_funding_record(page, reg, settled_on, confirm=True), new, the write half of a settle, proven safely with Mark watching before ever running for real (see the "How should I verify the Settle button" question, answered "You show me live"). Real UI flow, mapped step by step: the funding row's own 3 dot kebab menu, opened via `_open_funding_row_kebab` (polls for the button's own real position rather than a single evaluate call, this page's kebab was intermittently not there on the first try); clicking its "Settle" option triggers a genuine native `window.confirm()` dialog ("Are you sure you want to settle 1 funding records?"), NOT a DOM element, so it was invisible to every earlier DOM based search and Playwright silently auto dismisses any dialog nobody is listening for, which is why nothing visibly happened on first attempts. Fixed by registering a real `page.on("dialog", handler)` listener around the click (a `page.expect_event("dialog")` context manager was tried first and corrupted Playwright's own sync event loop, "This event loop is already running", because the click path it wraps also does its own blocking `time.sleep` polling internally, two different waiting mechanisms fighting each other; the plain listener has no such conflict). Accepting the dialog opens a "Settlement Date" modal whose title text ("Settlement Date") is the modal's own heading, not a field label the way every other modal in this project works, so there is no separate label to search for; the one real date field is found directly via `.ui-datepicker__display-value` (checking every match for one with a real, nonzero rect, the same hidden duplicate pattern already seen elsewhere on this page), then the existing `_pick_calendar_date` and a `.ui-button` matched on its own text "SETTLE" finish it. Verified in two stages before it ever ran live: a dry run (confirm=False, stops right before the final click) against the real EY69KYV proved every step up to the date field, confirmed via the API afterward that funding_settled_at was still null and funded_value unchanged; Mark then walked the real flow himself on the same car and sent a screenshot of the real Settlement Date modal (date field defaulting to today, a SETTLE button), which matched the automation's own mapping exactly.
    - Also removed a genuinely dead function found while wiring this up: `edit_funding_record` referenced `_click_date_field`, a helper renamed to `_click_ui_select_field` earlier in the same build and never updated at this one call site, so it could never have run; DealerKit's kebab menu only ever offers Settle and Delete anyway, no Edit, so a correction still means delete plus a clean `add_funding_record` (exactly how EY69KYV's own wrong figure was fixed earlier the same day). Removed outright rather than fixed and left unused, per house style.
    - daily_run.lecapital_funding_pass() (python3 daily_run.py --lecapital-funding): opens one continuous headed window (the same persistent profile every other headed pass already uses, so DealerKit's own login is normally already there), polls for LE Capital's own logged in signal (page off /auth/login and "Displaying all" visible in the body text, the site gives no simpler DOM hook), reads Current Stock then Stock History, then switches the SAME page over to DealerKit's Finance > Funding (polling for its own `a[title="Add Vehicle"]` button as the logged in signal), plans the sync, then works through to_add and to_settle in turn, one failure per car printed and skipped rather than stopping the batch (exactly how a Sold car's known add failure is meant to behave), mismatches only ever printed. First live run genuinely surfaced a real gap rather than a code bug: LE Capital's own login had never actually been saved to this automation's browser profile at all (the reconnaissance that built this feature used a separate browser tool, not this one), so the pass correctly timed out after 5 minutes waiting for a login nobody was there to complete and exited cleanly with "Nothing changed", exactly as designed, no partial state, proven by checking the run's own progress file and the process's own clean exit code. Needs a real `python3 login.py lecapital manual` (Mark present) before this pass can run unattended; not yet scheduled, deliberately, until that login has been done and a first full live run has actually completed end to end.
    - Wired the same way every other on demand pass already is: new `/api/run-lecapital-funding` in serve.py (`_lecapital_proc`, added to the busy check, added to `_stop_run`'s kill list, `lecapital_running` exposed on `/api/run-status`), a new "Sync LE Capital funding" button in the cockpit's Run modal under Other checks (`runLecapitalFunding()` in render.py's page JS, its own confirm dialog explaining the source of truth direction and that a value disagreement is only ever reported), `setRunning`/`pollRun` extended with a `lecapital` state so the button shows "Syncing..." and the page reloads when it finishes. 6 new tests (_add_months, plan_sync's to_add/to_settle/mismatched logic, the two lecapital.py reader fixes against real captured Current Stock and Stock History text saved as synthetic fixtures, data/fixtures/lecapital_current_stock_sample.txt and lecapital_stock_history_sample.txt, same "never a real captured business record as a permanent test fixture" convention already established). 286 tests pass.
  - SETTINGS PAGE GAINED AN INTEGRATIONS TAB, CONTROLS FOR EVERY BACK OFFICE AUTOMATION, 2026-08-24, same day (Mark: "the next obvious thing to do it update the settings page with controls for all the intergrations automations"). Four automations, each independently switchable: DealerKit stock read, DealerKit purchases push, Glass's on demand valuation, LE Capital funding sync (built earlier the same day, see above). Turning one off does three things at once, not just one: hides/greys its button in the cockpit's Run modal (a real HTML `disabled` attribute plus a title explaining why, not just a visual style, so it genuinely cannot be clicked), refuses a direct POST to its own serve.py endpoint even if some stale cached page still shows the button enabled, and (DealerKit stock only, the one of the four that also runs unattended) skips it in the scheduled daily run too. Three places, one flag, checked independently at each, so a disabled automation can never run by some path the toggle forgot about.
    - Replaces the old STOCK_FLAGS_ENABLED module constant in daily_run.py (a hardcoded True Claude had to edit by hand to switch DealerKit's stock read on when it replaced Clickdealer, see the 2026-08-19 entry above) with a real settings toggle, `dealerkit_stock`, following the exact same precedent already set for the pricing formula (retail_uplift, flat_spread), which Mark also asked to move from code only into an editable setting once he wanted to change it himself.
    - New pricing.py sibling settings namespace, deliberately NOT folded into pricing.DEFAULT_SETTINGS (integrations have nothing to do with the buying brain's gate or pricing rules, and folding them in would have meant `_validate_settings` needing to know about a second, unrelated shape): `daily_run.INTEGRATION_DEFAULTS`, a plain `{key: {"enabled": True}}` dict per automation, and `daily_run._integration_enabled(key)` (reads through the existing per process cached `_get_settings()`, same convention as every other settings lookup in this file, so a new call site can never forget it the way `assume_distance_ok` once did). serve.py's own `_integration_enabled(key)` reads fresh from `db.get_settings()` every call instead (this handler lives as long as the server does, unlike a single daily_run.py process), used to reject a disabled automation's own POST endpoint with a clear ValueError before it can spawn a subprocess.
    - Stored in the same settings key value table as the buying rules (db.get_settings/set_settings, JSON per key, no schema change needed), but under its own top level key, `integrations`, with its own save button and its own POST /api/integration-settings endpoint on the settings page, entirely separate from the buying rule Settings save and its `_validate_settings`, which only ever copies known buying rule keys out of whatever it is given, so an integrations key riding along inside that same POST body would have been silently dropped rather than validated or saved, confirmed by reading `_validate_settings`'s own code before assuming the two could share one endpoint. `_validate_integrations(d)` requires every one of the four known keys to be present with a real boolean `enabled` and rejects an unknown key outright (a typo here should fail loudly, not silently do nothing).
    - render_settings() gained a fifth top level tab, Integrations, alongside Overview and the four platform tabs, its own panel and its own Save integrations button (the two save buttons swap visibility with the active tab in `switchView`, so only the button that actually does something is ever on screen). Each automation's own card also shows a read only login status line, computed once at render time straight from its saved session state file's own mtime (`bidbrain.browser.state_path`, zero cost, no live check against the site itself), the exact same file every login.py and daily_run.py headed pass already writes to, so a session that has gone stale, or (LE Capital and Glass's, discovered live while building this) was never actually saved on this machine's automation profile at all, is visible on the settings page before an automation trips over it rather than only after. Genuinely useful the moment it shipped: it immediately showed both Glass's and LE Capital reading "No saved login yet on this machine", the LE Capital one exactly matching the real timeout `lecapital_funding_pass()` had just hit minutes earlier.
    - Verified live end to end against the real running server (restarted com.bidbrain.serve, launchd brought it straight back with the new code): GET /api/settings now returns a real integrations block; toggling LE Capital funding off through the real browser UI and clicking Save integrations correctly flashed "Saved", the cockpit's own Sync LE Capital funding button came back genuinely `disabled` with the off explanation as its title on the next render, and `python3 daily_run.py --lecapital-funding` correctly refused with the same wording rather than opening a browser at all; toggling it back on restored the button and the pass to normal, confirmed both via the API and a fresh render. 10 new tests (INTEGRATION_DEFAULTS' own key set, `_validate_integrations` accepting a well formed payload and rejecting a non dict, an unknown key, a missing key and a non boolean enabled value, a settings table round trip proving one integration's own save never touches another's, and render_page correctly leaving every button alone when no integrations are passed at all versus disabling exactly the one that is off). 296 tests pass.
    - A RELOGIN BUTTON UNDER EACH INTEGRATION, same session, straight after (Mark: "add a button under each intergration to relogin"). Each card gained a real "Log in to X" button, keyed to that automation's own site (dealerkit, glass or lecapital, DealerKit's two cards sharing the same site's button state since it is the same login). Two clicks, not one, because a manual login genuinely needs a person: "Log in to X" starts `python3 login.py <site> manual` as its own subprocess (a real headed window on the Mac, exactly what typing the command by hand would do); "I'm logged in, save it" (appears once that window is open, every other Log in button on the page disables itself meanwhile) writes the same `data/.login_done` flag file login.py has always waited on in manual mode, previously only ever set by a Claude session on Mark's own word in chat, now the same signal from a real button instead. None of the three sites here (dealerkit, glass, lecapital) have login.py's own URL based auto detect (`logged_in_url`, only Motorway, Carwow and Auction4Cars have that), so manual mode's flag file really is the only way any of these three ever finish.
    - New serve.py endpoints /api/relogin (validates the site against a small known set, starts the subprocess, blocked while any other headed pass is in flight) and /api/relogin-done (writes the flag, refuses if no login window is actually open, so a stray click cannot pre create the flag for a window that has not started yet). `/api/run-status` gained `login_running`/`login_site` so the page can poll and swap which card's own "I'm logged in" button is showing.
    - A real, pre existing gap found and fixed while wiring this in, not something this feature introduced: every one of the on demand pass endpoints (Glass's, DealerKit stock, DealerKit purchases, LE Capital funding) already checked each other before starting, since they all share the one persistent browser profile (`bidbrain.browser.open_context`'s own docstring: "Only one process may use the profile at a time"), but the plain "Run now" button's own endpoint only ever checked its own `_run_proc`, nothing else, a real latent race the six near identical repeated busy checks made easy to miss. Replaced all six with one shared `_any_proc_busy()` (checks run, glass, clickdealer, dealerkit, dealerkit_purchases, lecapital and now login together), including widening `_run`'s own check, so a relogin window (or any other headed pass) now correctly blocks a daily run starting underneath it and vice versa, not just the four newer on demand passes blocking each other as before.
    - Verified live against the real running server: clicking through the real settings page UI showed the expected mechanics but the confirm() dialog on "Log in to LE Capital" was silently auto dismissed by the automated browser tool driving the click (the same native dialog auto dismiss behaviour already documented for DealerKit's own Settle flow earlier this project), so the real server side flow was proven directly instead, POST /api/relogin for lecapital genuinely opened a headed `login.py lecapital manual` process (confirmed via `ps`, a real window on screen waiting for a login), `/api/run-status` correctly reported `login_running: true, login_site: "lecapital"`, a second POST for a different site correctly refused while it was still open, and reloading the real settings page correctly showed "Log in to LE Capital" disabled and "I'm logged in, save it" in its place while every other card's own Log in button was disabled too. Stopped cleanly afterward via the existing Stop endpoint (a real, already proven safe mechanism, not something new) rather than left open unattended, since completing it needs Mark's own real LE Capital credentials, this session was proving the mechanism, not actually logging in. LE Capital and Glass's both still genuinely have no saved login on this machine's automation profile, the real next step for the LE Capital funding sync to ever run unattended, now reachable with one click from the settings page rather than a terminal command. 296 tests pass (this feature is live browser automation, verified as above, not unit tested, matching every other login.py/daily_run.py headed pass in this project).
**v2.4.1 starts here, 2026-08-24 (after the v2.4 tag was cut).**
  - RELOGIN EXTENDED TO THE FOUR AUCTION PLATFORMS, AND DEALERKIT'S TWO CARDS CONSOLIDATED, same day, after v2.4 was cut and Mark actually used the new relogin feature (LE Capital's own login sorted live this way, see below; Glass's own login started but deliberately left for a later session). Two follow up asks in one message: "i think the auction houses need this way to relogin add these to the intergrations page" and "also clean up the Dealer Kit option under one DK heading with both sync options".
    - `serve.py`'s `_RELOGIN_SITES` widened from the three back office automations to all seven real sites (adds motorway, carwow, auction4cars, dealerauction), no other endpoint changes needed, `_relogin`/`_relogin_done` were already generic over any known site key. These four have no on/off toggle, deliberately, that already lives in the Run modal's own per platform checkboxes and each platform's own Settings tab, only the login mechanism is shared here.
    - render.py's Integrations tab restructured around a per SITE group rather than a per AUTOMATION row: `_login_block(site)` is now the one shared status line plus relogin button pair, called once per site. DealerKit's two automations (stock read, purchases push) render under a single "DealerKit" heading with one login block, each automation's own toggle and note demoted to a smaller `.integ-sub` row underneath (dashed divider, no login duplication); Glass's and LE Capital, each already a single automation, fall out of the exact same grouping code with no special casing needed. A new second card, "Auction house logins" (`_PLATFORM_LOGINS`, `platform_logins`, its own `{{PLATFORM_LOGINS}}` template placeholder), lists the four platforms the same way, login block only, no toggle markup at all.
    - Verified live: restarted com.bidbrain.serve, the real settings page now shows one "DealerKit" card with both toggles nested under its single login block (screenshotted, matches the design exactly), and a new Auction house logins card reads real ages straight off each platform's own state file mtime (Motorway and Carwow "Last logged in 4 days ago", Auction4Cars and Dealer Auction "2 days ago", all genuine, not placeholder text), confirming Motorway really does have a live `motorway_state.json` in real use now (an old CLAUDE.md note from 2026-06-09 about a `.unused` fallback file no longer applies, checked directly, no such file exists any more, `open_reader_context` is reading the real one). A POST to /api/relogin with a made up site name correctly listed all seven real ones back in its own error message. 296 tests pass (this round is render only markup restructuring plus a widened validation set, no new logic to unit test beyond what already covers `_validate_settings`/render_page elsewhere).
  - LE CAPITAL AND DEALERKIT LOGINS ACTUALLY SORTED, using the new relogin buttons for real for the first time, same day. Mark opened the DealerKit relogin window himself first (unprompted, most likely trying the new button), asked to switch to LE Capital instead; that DealerKit window was stopped cleanly via the existing Stop endpoint (nothing lost, it only ever writes a session file, no in progress save) and a real LE Capital login window opened in its place. Mark logged in and confirmed "im logged in"; `/api/relogin-done` wrote the flag, login.py picked it up within its own 2s poll, saved a genuine `data/lecapital_state.json` (18.6KB, a real session) and closed itself, confirmed both via the settings page (LE Capital's own card flipped to "Logged in within the last day") and by checking DealerKit's card too (still fine, untouched by any of this). LE Capital's daily funding sync, built and released in v2.4, is now genuinely able to run unattended for the first time since it was built, the real gap flagged at the end of that release. A Glass's login window was also opened the same way but deliberately left for Mark to finish another time ("lets come back to glass's"), stopped cleanly rather than left hanging while attention moved to the platform relogin request above; Glass's still has no saved login on this machine as of this entry.
