# BidBrain project status, part 21: Current: Dealer OS two way sync, v3.11 to v3.14 (September 2026)

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

- 2026-09-20 (v3.14.142): the same car won on two platforms, backlog item 2, answered. Steven picked "warn and hold everything" from three options. The finding first: the record keeping already copes AFTER a cancellation, because `cancelled_at` sits on the purchase row rather than on the registration, so a cancelled Motorway sale never drags the live Carwow one down (ND20NVZ and the Ford Puma, the two real cases). What was entirely missing was anybody being told it had happened. Each platform keeps its own row, keyed `(platform, vehicle_ref)`, and nothing joined them up: two live records, both fed into the accounts email as separate cars to pay for, both able to be sent to DealerKit, which finds a car by registration. The new rule is `db.double_wins(rows)`, pure and derived, never stored: any two LIVE rows sharing a normalised registration are a double win, whatever platform and whatever day, and a car with no readable plate is never matched to anything. It is computed once in `list_purchases` as a third pass after the canonical money (it reads `all_in`) and hung on every row as `double_win`, exactly the same "one place, everybody agrees" shape as the money funnel, so the Mac's page, the accounts email, the DealerKit preflight and the Dealer OS push cannot drift apart. The block carries two finished sentences written on the Mac, `advice` for the screens and `hold` for the DealerKit refusal, so the same car is never worded three different ways. It recommends keeping the cheaper sale all in and cancelling the dearer one, and names the saving; it recommends NOTHING when it cannot honestly choose, which is a sale with no price on file yet (`unsure`) or two sales costing exactly the same (`tied`). The holds: `unemailed_purchases` now reads every live row rather than only the unemailed ones and drops anything in a double win group, so a twin emailed days ago still holds the new one back, and nothing is stamped emailed while held so nothing is lost; `serve.dk_send_preflight` adds the refusal sentence, which covers both the Mac's own Send and Dealer OS's `purchase.dk_send`, the only door that sends a car whole since v3.14.3. The 16:15 automatic run needed nothing, it only does follow ups on cars DealerKit already has. On screen: a red panel above the Purchases table on both the Mac's page and Dealer OS, drawn from EVERY row rather than the filtered view so a platform tab or a price range cannot hide it, plus a tag on the registration, green Keep or red Cancel. `_note_double_win` writes a line on both timelines the moment the second win lands. Nothing to clear up and nothing to remember: cancel one sale and the group drops to one live row, so the warning and both holds lift themselves. Golden rule 1 is untouched, nothing here acts on a platform, it says which sale to keep and the person cancels the other. Dealer OS v4.5.13 carries the display side, the `double_win` block documented in BIDBRAIN_PUSH_CONTRACT.md as additive, schema still 1, and `records.doubleWinLines` names a car once rather than once per row. 19 new BidBrain checks; 3 new Dealer OS checks, 3,543 passing there, with tsc, eslint and next build clean. Four releases went out from other work while this was being written (v3.14.137 to v3.14.140, with v3.14.141 open), so the changelog section and this entry were rebuilt on main's own files rather than patched, and this takes v3.14.142. Touches Mark: yes, his copy gets the same warning and the same two holds. It only tells him, it places no bid and takes no platform action, and it needs no scheduled job, so it works his way unchanged.

- 2026-09-20 (v3.14.141): the Carwow star is judged by Carwow's own shortlist page, and the sync works. v3.14.137's read only deep read did its job on the first run: the Mac opened 14622219 (FL16MXJ, confirmed by Steven to be ON his Carwow shortlist) and 14586473 (ND19HZG, confirmed OFF) and logged both bookmark panels to data/logs/deep_read.log. They are identical, character for character: frame class listing__vehicle-favourite, panel classes listing-bookmark-component listing-bookmark-component--compact, activeIcon carrying listing-bookmark-component__icon--hidden, inactiveIcon shown, form action /dealers/listings/listing_bookmarks/<id>/toggle method post, no aria label, no bookmarked value attribute, no words. The deep block's page text carries no shortlist wording either (checked for shortlist, favourite, saved, remove from: zero hits on both cars). So the Carwow listing page renders the unstarred heart for every car whatever the truth. bookmarked_in_html was never going to work, on any markup, and neither would anything else read off that page. Steven supplied the answer in ten seconds when asked for it: https://dealers.carwow.co.uk/dealers/listings/filtered/shortlist, his own shortlisted vehicles page. New `carwow.SHORTLIST_URL`, `carwow.shortlisted_ids_in_html(html)` (pure, reads data-listing-id off the same _card_blocks every other Carwow list is read from) and `carwow.shortlisted_ids(page)` (through the logged in session with no cache headers, page by page like read_all_pages, raising on a bad status, a login screen or a redirect anywhere but /filtered/shortlist, because an unreadable page read as an empty shortlist would turn every starred car off). `set_watchlist` now asks that page before it opens anything: a car already in the wanted state returns True with nothing clicked, which is the important half, since the heart is one switch and a needless click turns a correctly starred car off. Otherwise it clicks once, with the v3.14.135 Stimulus wait and frame scoping unchanged, and waits for the shortlist page to agree. The listing page and its heart are no longer consulted at all; bookmarked_in_html and bookmark_marks stay for what they document and for the deep read log. Four new checks, 1,718 passing. Touches Mark: yes, his Carwow stars have never arrived either and this is what gets them there; no new setting, default or scheduled job, and the shortlist toggle is still the only platform action taken.
- 2026-09-20 (v3.14.140): a re registered car stops reading as missing on DealerKit, the first of the ten items carried over from 16 September. The cause, confirmed against the code: `dealerkit_write._find_vehicle_id_by_reg` scanned DealerKit's stocklist for ONE plate, normalised but exact, and every DealerKit path went through it. When a seller keeps their private plate the platform lists the car under that plate while the car itself sells, and so lands on DealerKit, under the plate underneath, which CLAUDE.md's own listing rules already name (Motorway `details.sellingVrm`, Carwow's plate transfer field, T18TBA selling as YL66HWE). BidBrain read that plate for the valuations from June onwards and threw it away afterwards: nothing in the database held it, so no lookup could have tried it. So those cars read as having no DealerKit record at all, permanently, which also means no check, no chip, no transport invoice and a Dealer OS card saying Not on DealerKit (`onDealerKit` in Dealer_OS reads `dk_check.vehicle_id`, so Dealer OS needed no change of its own). Three parts. `plates.candidates` turns any number of values into the real, tidied, de duplicated plates, dropping anything not plate shaped so a bad read can never widen a search. `dealerkit_write.find_vehicle_by_plates` searches them all in ONE pass of the stocklist and returns the id with the plate it was found under, ordered so the recorded plate always wins when DealerKit somehow holds both; `_find_vehicle_id_by_reg` is now a thin wrapper on it, `vehicle_id_from_map` does the same against an already read map for the bulk passes, and `other_plate` reads the second plate off a purchase row. And the pair is now KEPT. A `plate_pairs` table records the two plates the moment a run reads them, written from `save_assessments`, which every run path goes through after its own detail reads; that placement matters, because Motorway states the plate in `gate_details`, which writes to no listing cache at all, so on the very platform CLAUDE.md's worked example comes from the pair was being read and thrown away every single day. It is kept for good, since a car bought today can reach DealerKit weeks later, long after every listing cache has rotated. Purchases gained a `selling_vrm` column filled once by `_fill_purchase_selling_vrm` from `_plate_pair_map`, which reads `plate_pairs` and, for the Carwow cars whose pair landed there before this existed, `listing_detail_cache` as well, BOTH ways round, so a purchase recorded on either plate knows the other. It is editable by hand on the card, under Sells on plate, with the same reasoning as reg and mileage, refusing anything not plate shaped and refusing to store the car's own plate twice. Wired through `add_due_in` (so `push_purchase` too), `push_followup`, `check_dealerkit_record`, `_dealerkit_stages`, `dealerkit_check_pass` and the sale price read, whose `by_reg` map is now keyed on every plate a purchase is known by because those rows come from DealerKit's own sale notices and so carry DealerKit's plate, not BidBrain's; `db.dealerkit_stock_vehicle_id` takes the other plates too. The two funding functions are deliberately untouched: their reg comes from DealerKit's and LE Capital's own lists, so both sides already agree. Creating a record still types the recorded plate, unchanged: which plate a NEW DealerKit record should be created under is a separate question and is Steven's call, not a silent change here. The check pass now prints and stores `matched_plate` when the match came from the other plate, and the purchase card's DealerKit panel shows it, and names both plates when it found nothing. 22 new checks, 1,754 passing. Touches Mark: yes, the same lookups run on his copy and the same private plate cars have been invisible to them there too; nothing is automatic that was not automatic before, no buying rule, no default, no scheduled job and no new write to DealerKit, so it is safe with the platforms delivering as well as with his own driver. Not verified against a live DealerKit: this container never got the browser certificate installed, so the reading above is from the code and from CLAUDE.md's own record of the plates, not from a real stocklist. Worth a live check on the first private plate car that comes through.

- 2026-09-20 (v3.14.139): a stray tag, and the Mac no longer falls for one. v3.14.138 was released from the Actions tab with the version typed as "3.14.138" rather than "v3.14.138", so `gh release create` made a second tag on the same commit and published the auto generated notes instead of that version's CHANGELOG section. Re running the workflow with the v produced the real `v3.14.138` release, notes and all, and it is the newest release, so the Macs get the right thing. The bare `3.14.138` tag could NOT be deleted from a hosted session (`git push origin :refs/tags/3.14.138` answers HTTP 403, the session's token cannot delete refs), and the GitHub tools here have no delete for a ref or a release, so it is still on the repository and Steven can remove it from the Tags page if he wants the history tidy. The real problem was never the clutter: `serve._current_version` took `git tag --points-at HEAD` and returned the first line, git lists tags in name order, and "3.14.138" sorts before "v3.14.138", so a Mac sitting exactly on the release would have read its own version as the bare tag while `_latest_version` read "v3.14.138" off the newest release. `update_available` is simply `latest != current`, so the Software update card would have offered an update that was already installed, permanently, and Steven's Mac (auto update on since v3.14.127) would have taken it every couple of minutes, checking out the same commit and restarting itself each time. New pure `serve._release_tag(tags)` picks the tag matching the release convention (`^v\d`) when a commit carries more than one, falling back to what is there when none does, so a duplicate tag can never fool the version check again whoever makes it. Five new checks, 1,733 passing. Touches Mark: yes, his Mac reads its version the same way, though with auto update off his Update button would simply have looked wrong rather than restarted anything. Nothing about buying, DealerKit or any scheduled run changes.

- 2026-09-20 (v3.14.138): the stock gap badge and the top picks were counting one real model as two. `clickdealer.model_key`, which the stock badge (`daily_run._stock_flag`), the sold history note (`_history_flag`), the taste match (`_taste_match`) and, through both, the top picks (`_pick_score`) all key through, took the first token of the make plus the first token of the model and nothing else. That splits a model wherever a platform prints the engine badge where the model belongs: a BMW 118D keyed to "bmw 118d" and never met the "bmw 1" already in stock, so a car he holds four of read as a gap and gained the gap's two points in the picks; a Mercedes A 180 never met an A-CLASS; an "MG Motor UK ZS" keyed to "mg motor"; and Motorway's own truncated "Insig" never met an Insignia. CompMatch had already hit and solved exactly this for the suggested bid on 2026-08-25 (`bid_suggest.model_key`, which canonicalises the BMW series digit, the Mercedes class letter, the MG Motor UK make and Insig). So `model_key` now delegates to that function rather than carrying a second, worse copy of the same idea, lower cased so the stored key case and every existing caller are unchanged. Two small pieces around it: `bid_suggest.model_key` also reads "Mercedes Benz" spelled with a space as the make, because a make and model pair joined into one name spells it that way where a listing title uses the hyphen; and hyphens inside the model still read as spaces, as they always did here, so a Honda CR-V and a Honda CR V still meet. Stock counts are keyed at read time from the raw make and model in `stock_snapshots`, so the badge is right on the next run with no migration; `learned_models` stores the key it was built with, so `db.learned_keys_stale` spots a table built under the old rule and the daily run rebuilds it once from the sales history already on file, and a copy with no sales history is never rebuilt out from under it. Fourteen new checks, 1,723 passing. Annotation only, golden rules untouched: nothing about pricing, the hard gate, the bans or any platform action changes, and no valuation or read is spent. Touches Mark: yes, his stock badge comes from DealerKit through the same `model_key` and groups the same better way on his Mac. No new setting, no new default, no new scheduled job.
- 2026-09-20 (v3.14.137): the Carwow shortlist panel on the record, and Steven settling what is actually broken. He opened his own Carwow shortlist and it held three cars, SD16VSE, FL16MXJ and HK16CWF. HK16CWF was NOT on it when he looked earlier the same evening, and the only thing that touched it in between was the 01:51 watchlist pass, which reported it as a failure. So the click lands, Carwow does it, and the read back is what is wrong. That explains the whole history of this sync in one line: a car goes on, BidBrain reads it as still off, calls it a failure, and the next attempt clicks the same single switch again and takes it straight back off, which is exactly the churn v3.14.136's give up on a sent click had already stopped. What is still not known is how Carwow marks a shortlisted car. The activeIcon and the hidden class were read from Carwow's own published front end assets, which need no login, but a rendered panel on a real listing cannot be read that way, and the panel v3.14.136 put in the failure text was cut by the 400 character log line limit before it reached the icons, while its before the click copy came back empty every time. So this release takes the reading out of the clicking path altogether. New `carwow.bookmark_marks(html, listing_id)` pulls the panel apart into short separate lines, each well inside 400 characters: frame classes, panel classes, the classes on every marked icon, the form's action, method and fields, any aria label or title, and the words the panel shows, with the forgery token dropped because it changes every read and says nothing. `carwow.deep_read`, the shortlist deep read, which opens a car's page and only ever reads it, now writes those lines to data/logs/deep_read.log for every Carwow car it opens. That gives a clean experiment with nothing clicked: deep read FL16MXJ, which Steven has confirmed is on his shortlist, beside ND19HZG, which is not, and the difference between the two panels is the state marker `bookmarked_in_html` should be reading. Six new checks, 1,714 passing. Touches Mark: nothing in the sync changes and nothing is clicked, so his copy behaves exactly as it did; his Carwow stars have never arrived either and the fix that follows is the one that reaches them.

- 2026-09-20 (v3.14.136): what v3.14.135 actually proved, and stopping the harm while the rest is worked out. v3.14.135 went out and the sync was run from a hosted session through Dealer OS's own run route. The new message came back for all five pending Carwow cars: "Carwow answered POST 204 and still shows it the old way". So the lazy controller half of the diagnosis was right, the click now genuinely lands where before nothing was ever sent, and the rest of that diagnosis was wrong: Carwow takes the toggle and answers its usual success. Steven then looked at his own Carwow shortlist, which no session in this project can open, and reported that FL16MXJ and SD16VSE WERE on it while ND19HZG, YO17ABF and MW66BZS were not, then sent a screenshot showing the shortlist holding exactly three cars, SD16VSE, FL16MXJ and HK16CWF, all three reading "On sale tomorrow". Clicks that night, from the run log: FL16MXJ once, HK16CWF once, the other four twice. So neither a straight toggle count nor a straight refusal explains it, and every field BidBrain holds on the five (reserve, grade, keeper date, pick rank, listing id, distance, flags) was compared and separates them not at all. What is certain is that the click reaches Carwow and the reading of the result does not match what Carwow shows, which makes retrying actively harmful: the shortlist button is a single switch, so a car read as not starred when it really is starred is turned OFF by the next attempt, and three cars sitting off after two clicks each is consistent with exactly that. Two changes, both narrow. `carwow.BookmarkNotConfirmed` carries `sent`, true when a toggle request genuinely reached Carwow, and `daily_run.watchlist_sync_pass` passes it to the new `db.mark_watchlist_synced(give_up=...)`, which counts that attempt as the last one allowed; the row keeps its real error for whoever looks, and starring the car again in the app re queues it from scratch with attempts back to 0. And a failure now carries Carwow's own panel markup for that car (`carwow.bookmark_frame`, `carwow.bookmark_panel_words`, trimmed to 400 characters with the svg paths stripped) into the log and the error, because no session here can open a real Carwow listing to see how a shortlisted car is actually marked, and the alternative is guessing, which golden rules 4 and 5 forbid. Four new checks, 1,709 passing. Still open, and this is the real remaining question: what the panel looks like for a car that IS on the shortlist, which the next failure will now report. Also worth a live check once a session can open Carwow: whether the three that did not stick are refused by Carwow itself, which Steven can settle by clicking the heart on listing 14586473 by hand and seeing whether it holds. Note that the environment's browser certificate step is now in the claude.ai environment setup script (Steven, 20 September), so a NEW thread from here on can open Carwow, Motorway and DealerKit; this one, started before that, cannot. Touches Mark: yes, the same two changes reach his copy, and his Carwow stars have never arrived either. No buying rule, setting or scheduled job changes; the only platform action is still the shortlist toggle.

- 2026-09-19 (v3.14.135): the Carwow watchlist sync has never once worked, and now it should. Steven: "This watchlist sync keeps having issues, I think it's on the Carwow cars, but I could be wrong." He was right. The Mac's own run log for the day shows Motorway syncing every star cleanly ("YC20YZO: starred on Motorway", "2 of 2 synced on Motorway") and every Carwow row failing identically, in both directions, every pass ("FL16MXJ: failed, Carwow listing 14622219 still shows bookmarked=False after trying to set it to True", "0 of 2 synced on Carwow"). That is the same failure the build recorded in August (docs/status-17) against an already won listing, and the "a bought car's bookmark is locked server side" theory written down then is wrong: it fails exactly the same way on genuinely live listings. The real cause came out of Carwow's own published code, which needs no login to read and so was read rather than guessed: `/assets/controllers/index-*.js` calls `lazyLoadControllersFrom('controllers', application)`, so the JavaScript behind a Stimulus controller is only fetched, by a dynamic import, once its element turns up in the DOM, and that import resolves after the page's own DOMContentLoaded. `set_watchlist` navigated with `wait_until="domcontentloaded"`, waited for `_BOOKMARK_BTN_SEL`, which is server rendered and therefore present immediately, and clicked. A click that lands before the controller has connected does nothing whatsoever: no icon flip, no `formTarget.requestSubmit()`, no request. Nothing was ever sent to Carwow, which is precisely what a state that never changes looks like. The bookmark controller itself is unchanged (fetched and diffed against the August reading: same targets, same classes, same requestSubmit), so no Carwow redesign is involved. Three changes in `carwow.set_watchlist`: it now waits on `page.wait_for_function` asking Carwow's own Stimulus application (`window.Stimulus`, published by `/assets/controllers/application-*.js`) whether the controller is genuinely connected to this car's element, so the wait ends when the button is really live rather than after a fixed sleep; every read and the click are scoped to `turbo-frame#listing-bookmark-<id>`, this car's own panel, so another card's heart on the same page can never be read or clicked by mistake (the old code took `.first` anywhere on the page); and the confirmation is taken from `page.request.get(url)` through the logged in session with no cache headers, parsed by the new pure `carwow.bookmarked_in_html(html, listing_id)`, instead of a `page.goto` the browser may answer out of the shared persistent profile's own cache, with the old reload kept as a fallback for a page whose heart cannot be read. The failure message now names which of the three it was ("Carwow's own star button never came alive on the page", "the click sent nothing to Carwow", "Carwow answered POST 204 and still shows it the old way"), golden rule 5, so if this diagnosis is wrong the next run says so plainly instead of repeating the same bare mismatch. Four new checks in test_pricing.py over `bookmarked_in_html` (hidden heart reads False, shown reads True, another car's frame is never read as this one's, an unreadable page is None and never False), 1,705 passing. Nothing in the buying rules, no new setting, no new scheduled job, and the only platform action is still the shortlist toggle, golden rule 1. NOT yet proven against a live Carwow listing from a hosted session: this container cannot open Carwow in a browser (the agent proxy certificate import the Dealer_OS hook does is refused by the auto mode classifier when both repositories are cloned, so the session's working root sits above that settings file), so the real proof is the next sync on the Mac, whose own log now says either that it synced or exactly which of the three it was. Touches Mark: yes, his Carwow stars were never reaching Carwow either and are fixed the same way.

- 2026-09-19 (v3.14.134): No Carwow cars reached Dealer OS for four days, Steven raised it on 19 September. It was not the login, the saved filter or the reserve price. Checked from a hosted session with the account's own sign in: Carwow's stock list returned 39 auction cars, `parse_listing` read all 39, `_list_gate_ok` passed them, the detail read worked, and the CAP clean stand in filled `reserve` exactly as it has since 2026-08-19. What had changed was one line on the car's own page. Carwow added an Ownership history panel, with a link to it beside the previous owners count, so the line under "Former keepers" now reads `2 view details` instead of `2`. `enrich_from_detail` tested that line with `owners.isdigit()`, which is False for `"2 view details"`, so `car.owners` stayed None on every Carwow car, and `pricing.gate_failures` rejects a car with no owners count outright ("Previous owners count is missing, so it cannot pass the gate"). Motorway reads owners from its own CSV and detail data, so it was untouched, which is why the list looked normal and only Carwow vanished. New `carwow.former_keepers(line)` takes the count at the very front of the line (`re.match(r"(\d+)(?!\S)")`) and nothing else, so a line that changes shape again leaves owners unset and the car is held rather than priced on a guess, golden rule 4. Proven live against the account's own stock on 19 September: 10 cars enriched, every one now carrying a real owners count, 7 passing the whole hard gate and the 3 rejections all genuine buying rules (one grade 4, two reserves over £11,000), against 0 of 10 passing before the change. Every other field on that page still reads correctly, checked line by line: CAP clean, Start of current keeper, VAT qualifying, Private plate, Plate transfer, Mechanical faults and Warning lights are all unaffected, "Former keepers" is the only line Carwow changed. Two things worth recording while that page was open, neither a fault: Carwow has replaced the reserve price with an Autotrader Retail Valuation everywhere it used to show one (the card footer, the floating box on the detail page, the bid modal, and its own filter fields, now `q[retail_valuation_gteq]`/`q[retail_valuation_lteq]`), which the CAP clean stand in from 2026-08-19 already covers and the retail figure is still never used; and the account has exactly one saved filter, id 1741, which returns the same 39 cars as the unfiltered list, so the filter is not culling anything.

  The second half of this release is why nobody saw it. The run already counted the rejection reasons and printed the commonest six whenever a platform read cars and none passed, added 2026-09-08 after 87 Motorway cards produced 0 passers with no reason shown. But it printed them to the run log only, and then wrote `record_run_status(key, True, "0 cars")` a few lines later, so the Run modal showed Carwow as a healthy read with nothing in it, four days running. That case now records a failed run status carrying the commonest reason ("Read 39 cars and none passed the gate. Commonest reason, 39 of 39: Previous owners count is missing..."), and the success line is skipped so it cannot paint over it. A genuinely quiet day now reads the same way, which is intended: a whole platform's stock arriving and not one car getting through is worth a look either way, and the alternative is what just happened. Golden rule 5.

  Touches Mark: yes, both halves. His Carwow cars were failing the same way on his own Mac and come back with the update; and his Run modal will now show a platform that read cars but shortlisted none as a failed read. No new setting, no new default, no new scheduled job, no change to any buying rule.

- 2026-09-19 (v3.14.133): Motorway damage, the same job as v3.14.132 did for Carwow, and it turned up a worse bug than expected. Motorway was assumed to be fine because `motorway_gallery` already returned damage photos. It was not. Motorway states a damage in two places, the words in `conditionAndDamage` (`hasDents`/`dentsDesc` = "1 small (0-5cm)") and the picture in the gallery (an image with `category: damage`, `kind: damage_dents`, `title: "Dents"`), and `from_motorway` simply concatenated the two lists (`d["damage"] = (dmg + damage_photos)`). So one fault reached the screen as two entries, the words with `photo_url` None and then a bare label with the photo, and neither entry had both. Worse, `motorway_gallery` counted ANY image with `isDamaged: True` as damage, so wheel and tyre photos landed in the bodywork list, undoing v3.14.131 (which moved wheels/tyres to `tyre_notes` precisely because Steven read them as bodywork) through a path that fix never touched. Read live from a hosted session on 2026-09-19 against three cars on the account's own shortlist: K12 KJD (Audi Q3) showed 5 "areas of damage" that were all wheel and tyre photos, with no bodywork damage declared at all; WJ68 VZS showed 10 entries for 3 real faults; FM18 TGN (Audi A5) showed 12 for 4. New `motorway_damage(cond, damage_photos)` joins the halves: for each declared fault it matches the gallery photos whose `kind` or `title` carries that fault's token (dent, scratch, paint, missing, windscreen), splits the description on newlines, and pairs line i with photo i. The live data backs the pairing exactly, 1 dent line to 1 dent photo, 2 scratch lines to 2 scratch photos, 1 windscreen line to 1 windscreen photo, every time. Matching is a token substring against kind AND title, never an exact name, because only `damage_scratches`, `damage_dents` and `damage_windscreen` have been seen live and inventing the paint and missing parts names would be a guess. Nothing is lost: a damage photo matching no declared fault is still kept under its own title, a fault declared with no photo still reads, and the wheel and tyre photos are still in `photos`, just not called bodywork. `motorway_condition` is kept as a thin wrapper (`motorway_damage(cond)`) so its own check still stands. The three cars now read 0, 3 and 4 entries, each with its photo. Fixture at data/fixtures/motorway_damage_sample.json (all three cars, photo urls redacted the way the existing Motorway fixtures are), 7 new checks plus 2 existing ones updated where they pinned the old behaviour. Note the pro.motorway.co.uk/vehicles list page itself was erroring in the browser that morning ("There was an error!", no blocked hosts, session otherwise healthy), so the cars were reached through /api/shortlisted-enquiries and /vehicles/<id> directly; worth knowing if a future session cannot get a stock list. Touches Mark: only in that his Motorway cars get the same corrected damage list. No new setting, no new default, no new scheduled job.

- 2026-09-19 (v3.14.132): Carwow damage photos. The shortlist deep read listed a Carwow car's declared damage with no photo against any of it (`deep_read.from_carwow` built the list from the page's visible words and hardcoded `photo_url` to None, and `carwow_photos` flattened every imgix URL on the page into one pile). Motorway kept the link because its page hands over structured photo categories; the working assumption had been that Carwow simply does not say. It does. Read live from a hosted session against listing 14611801 (DN17NWS, BMW 1 Series) on 2026-09-19: the condition report puts each damage in its own `listing__vehicle-condition-damage-item__detail` block holding where it is (`<strong>`), what it is (`..damage-item__description`) and the photo of it (the `<img>` inside `..report-item-damage-label`), under a group heading (Scratches, Scuffs, Dents, Chips, Alloys) that says "No damage reported" and carries no blocks when there is nothing wrong. New `deep_read.carwow_damage` reads those blocks, giving entries like "Rear driver alloy: Scuffed alloy" with the right photo beside each. A second marker worth knowing: in the main gallery an ordinary photo has a meaningful alt (driver_front_side, dashboard, service_history) while a damage photo has alt "damage_generic". Deliberately NOT used: the page's `#damage-gallery` dialog and the `data-gallery-opener-starting-index-value` each block carries. That gallery is a swiper that repeats its slides to loop (ten img tags for five damages), so reading it by position pairs the wrong photo with the wrong fault. Proven on six live listings: parsed counts matched each page's own "N areas of damage" summary exactly (5, 2, 7) and the three cars with no damage all genuinely said "No damage reported"; every damage had its own distinct photo, and every damage photo string matches its entry in `carwow_photos` so the screen can show it from either. Fixture at data/fixtures/carwow_condition_report_sample.html, eight new checks in test_pricing.py. A page with no condition report (an older saved page, or a shape change) still falls back to the page's words exactly as before, so nothing that reads today stops reading. `bidbrain/readers/carwow.py` needed no change at all: its `deep_read` already hands over `page.content()`. Touches Mark: only in that his Carwow cars get the same better damage detail. No new setting, no new default, no new scheduled job.

- 2026-09-18 (v3.14.131): Steven read a live car on the shortlist screen after the fourth round of Motorway field mapping (v3.14.130) and reported tyres, warning lights and mechanical still missing from Condition. They were not missing, they were there — folded into one "Damage" bullet alongside bodywork faults, so a tyre problem or a warning light read as absent unless you noticed it buried in that line. Wheels/tyres (`tyre_notes`) and a dashboard warning light (`warning_lights`) are now their own fields in the deep block, not merged into `damage`, which stays bodywork only (dents, scratches, paint, missing parts, windscreen, smoked in). Confirmed against the live run log (manual_run.log, deep_read.log, 2026-09-18): Motorway's real field names (`hasTyreProblems`, `hasWarningLights`, `wheelsAndTyres.*`) were already right, the bug was purely how the answer got grouped before it reached the screen. "Mechanical" has no field on any platform read so far, never invented one; a mechanical fault only ever shows up as free text inside the MOT's own advisories, which already has its own line on the screen.
- 2026-09-17 (v3.14.127): the Mac updates itself. INTEGRATION_DEFAULTS gained auto_update (off), render._INTEGRATIONS lists it under the Dealer OS site, serve._auto_update_loop (a daemon thread, every two minutes when idle) runs the same CockpitHandler._update the button runs when _version_block says a newer release is published. Off by default so Mark's Mac keeps the button; Steven's is switched on from Dealer OS.
- 2026-09-17 (v3.14.125, the same session that built Dealer OS v4.1.0's shortlist screen): the shortlist deep read. Dealer OS logs a shortlist.read action naming the starred cars; the sync queues them (db.deep_reads), the server starts the quiet deep_read run (daily_run.deep_read_pass, runs.py) or the sync loop's catch up does on the next quiet moment; readers/motorway, carwow, dealerway and auction4cars each gained a deep_read(page, url, asked_at) that opens the car's page in the saved login and hands what it shows to bidbrain/deep_read.py, the pure parsers (tested on fixture text: the DealerWay detail fixture, synthetic Motorway page data, Carwow words, Auction4Cars HTML); the push hangs the answer on each car as `deep` (dealer_os_push.build_payload). Dealer Auction answers with an error, it has no car page read. Motorway's field names were NOT confirmed against a live page in the session that built this (no Motorway login there): from_motorway looks for the names Motorway is likely to use and writes every name it saw to data/logs/deep_read.log on each read, so the first live read shows which still need mapping. A field the page did not show is null, never a guess. Touches Mark only in that his Mac gains the same button; nothing about buying, collection or DealerKit changes.

- 2026-09-06 (v3.14.10, a session driving Dealer OS hands off for Steven): the first send of a hand added car from Dealer OS's DealerKit tab (TE57DOS, a made up test plate) died twice in DealerKit's Add Vehicle window, and nothing said so anywhere Dealer OS could see. Fixed: the send waits up to 15 s for DealerKit's answer instead of 3, looks the plate up before calling it a failure, carries DealerKit's own words ("We couldn't find a vehicle matching those details, use the form below to describe it"), and every failed send is written to the car's timeline by BidBrain, not only an expired login (purchases_run.push_dealerkit, dealerkit_write.add_due_in, _no_confirmation_message, _modal_words). Released through the Release workflow and applied on the Mac Studio from Dealer OS's own Update now button. The test car was then cancelled and deleted from Dealer OS. See DEALER_OS_HANDOVER.md.
- Decisions log:  
  - DEALER OS COCKPIT, PHASES 3 AND 4: RUN CONTROL, SYNC LIGHT, DISPLAY
    SETTINGS BOTH WAYS, BELL NOTICES, 2026-09-02 (Steven: "continue with the
    build, dont ever retire the macs own cockpit until i have confirmed
    twice!"; nothing here touches the Mac Studio's own cockpit, which stays
    the safety net). Dealer OS branch bidbrain/cockpit, commit 91f88c6, on
    PR https://github.com/CarDealer-OS/Dealer_OS/pull/32; BidBrain local commit
    on main (not pushed, not tagged, v3.11 when Steven says).
    - THE LIGHT AND THE BAR. src/components/bidbrain/cockpit/run-state.tsx
      polls GET /api/bidbrain/status (the Mac's latest heartbeat) every 10 s
      idle, 3 s while something runs or a request waits; a heartbeat older
      than 60 s reads as "the Mac is not reachable" (grey, Run disabled).
      topbar-actions.tsx paints the run button as the light with the same
      sl-* classes as the cockpit (green spinning while running, amber
      while a request waits for the Mac, red on a failure, a green tick for
      ten seconds after a finish, all from the Mac's own _sync_light state,
      never re derived). run-bar.tsx is renderProgress ported, with Stop,
      staleness judged against the Mac's own clock in the heartbeat.
    - THE RUN MODAL (run-modal.tsx): two tabs, Auction houses and
      Maintenance, rows from the heartbeat's run_status/site_health/
      platform_enabled/hidden_connections/integrations, select all per tab,
      auction houses ticked by default. Run logs one run.start per item
      (POST /api/bidbrain/runs, {key, platforms, body}); the Mac's pull
      calls serve._start_run through the hook, and its next heartbeat's
      last_command names which request it answered so the page clears its
      own "asked" state. Nothing is ever started by Dealer OS itself.
    - DISPLAY SETTINGS BOTH WAYS: a Display section in the More drawer
      (SUPER_USER and SALES_MANAGER) for how Details opens and the price
      ceiling, saved via PATCH /api/bidbrain/settings which also logs
      settings.set for the Mac. The push route now calls
      settings-store.applyMacSettings (newest stamp wins, syncedAt kept
      per key in Dealer.settings.bidbrain) so a change made on the Mac
      lands in Dealer OS and an older Mac stamp never overwrites a newer
      Dealer OS choice; both directions proven live.
    - BELL NOTICES: lib/bidbrain/notices.ts turns the push's notices (kind
      other than "ok") into Notification rows for every manager, one per
      sentence per day, href /admin/bidbrain?openrun=1. Written with the
      per dealer client, not notify(), which is session bound (the global
      prisma client throws "Missing x-dealer-id header" from a machine
      route, found live).
    - TWO REAL FINDINGS: (1) the push's page level blocks (settings,
      notices, platforms, run_checks...) sit at the DOCUMENT ROOT, not
      under a "page" key, a first version of the route read doc.page and
      silently did nothing; (2) serve._dealer_os_snapshot referenced
      daily_run without importing it (circular at module level), so the
      live sync loop's every tick would have failed; fixed with a local
      import and serve.py restarted.
    - Lint purity rules (React compiler) forced three restructures: no
      setState inside an effect (the modal's default ticks became a null
      means defaults state), no self referencing scheduler (one loopFrom
      callback declared before the effect that uses it), no Date.now() in
      render (the finish flash is state cleared by a timer).
    - Verified live against the local Dealer OS with real heartbeats and
      pushes from the Mac Studio: modal rows match the Mac's real run
      board, run request round trip (logged, pulled into a temp database
      with a fake starter, cleared on the answering heartbeat), running
      and finished light states, Display change both ways, notice dedupe
      on a repeat push. Every test row (actions, the TEST NOTICE
      notification) removed from the local database afterwards; the Mac's
      real serve.py still points at production, which has no sync routes
      until PR 32 merges. 1,093 BidBrain checks, 15 Dealer OS tests,
      tsc and eslint clean.
  - LIVE CHECK OF DEALER OS AGAINST THE MAC, TWO SPACING FIXES, AND A REAL
    SETTINGS CORRUPTION FOUND AND UNDONE, 2026-09-02 (Steven: "there is a
    few spacing issues and layout issues that dont quite look the same or
    function the same as on the mac version"). Measured both pages at the
    same width with the same JS rather than by eye. Most of the difference
    was STALE DATA: the live site had only ever received a push from before
    the widened document, so engine chips, badges, Top picks, CompMatch and
    the Details counts were missing; one real `--dealer-os-push` fixed it.
    Two genuine layout gaps, fixed on Dealer OS branch
    bidbrain/cockpit-spacing (PR 35): the toggle row sat outside the padded
    content column (66px to the first section, the cockpit has 26px), and a
    wrapper div stacked the Top picks link under the view chip (row 70px
    tall, the cockpit's is 48px). Details opens the same pop up on both
    (Steven's own details_style is popup; production's Dealer OS copy took
    that from the push, applyMacSettings working for real).
    - THE CORRUPTION: `db.settings_updated_at()` showed three rows stamped
      with a TEMP FILE PATH. During today's earlier work the `set_settings`
      argument order was briefly (values, updated_at, path); a test run in
      that window wrote its throwaway banned_makes ["ford","audi"],
      value_caps [one Fiesta cap] and max_mileage 80000 into Steven's REAL
      settings table. No run had used them (last run 2026-09-01 17:10).
      Restored byte for byte from data/bidbrain.db.backup-20260902-005602-
      assessments-dedupe (every other differing key was a genuine live
      change with a real timestamp), the dead max_mileage row deleted.
      set_settings now refuses a non timestamp updated_at (a test pins it),
      and the whole suite was proven not to touch the real database by
      hashing data/bidbrain.db before and after a full run (identical).
      Lesson: any test that takes a database path positionally is one
      signature change away from writing into the live database; hash the
      real database around the suite whenever db signatures change.
  - PURCHASES, UNDER OFFER AND DIDN'T WIN NOW RIDE ALONG IN THE DEALER OS
    PUSH, 2026-09-02 (the Dealer OS session told Steven those three pages
    could not be built because "the Mac Studio does not send that
    information yet"). dealer_os_push.records_block(purchases, under_offer,
    lost_bids, purchase_settings) adds four top level lists to the same
    push document (schema still 1, additive): the exact rows the Mac's own
    pages get from serve._purchases_with_bias / db.list_under_offer /
    serve._lost_bids_with_bias, minus the two local file path fields
    (document_paths, v5_local_paths, LOCAL_PATH_KEYS), plus the four
    purchase_* settings the Purchases page is built from. Built inside
    dealer_os_push_pass with a lazy `import serve` (serve imports daily_run
    lazily, so this direction is safe), never fatal to the car list. The
    size guard trims purchase galleries alongside the car galleries. Read
    only on the Dealer OS side: no purchase.* action kinds exist yet, so an
    edit made there does not come back; documented in DEALER_OS_PUSH.md
    (copied to Dealer OS's BIDBRAIN_PUSH_CONTRACT.md). Real push: 121
    shortlisted, 9 held, 215 purchases, 83 lost bids, 1.55 MB, 200 from
    the live site. 10 new tests, 1,099 pass. Tagged v3.11.1.
  - THE DEALER OS PUSH GAINED THE PURCHASE TIMELINE AND THE LOST PAGE'S
    VIEWS, A DOCUMENT UPLOAD CLIENT, AND STEP ONE OF THE PURCHASES WAY
    BACK, 2026-09-02 (the Dealer OS session's own list, relayed by Steven:
    purchase_events and lost_bids_views, lasting document links, then
    purchase.* action kinds starting with the everyday fields).
    - Push: records_block gained purchase_events (db.all_purchase_events,
      every timeline row keyed by purchase_id) and lost_bids_views (the
      settings key). Both documented in DEALER_OS_PUSH.md.
    - Documents: on Steven's Mac EVERY service history and V5 scan is a
      local file (data/purchase_docs), Motorway's too, not just Carwow's,
      so no lasting link existed for any of them. New bidbrain/
      dealer_os_files.py: before each push, each local file not yet sent
      is PUT once to Dealer OS's own file store (PUT /api/bidbrain/
      documents?reg=&name=, bearer, raw bytes, reply {url}), the link
      remembered in a new dealer_os_files table against the local path,
      and with_links() swaps the path for the link in the push; a path
      with no link yet is dropped, never sent. Dealer OS has to BUILD that
      route (contract in DEALER_OS_PUSH.md, its media bucket in src/lib/
      media/storage.ts is the obvious store); until then the client sees
      the 404 and notes "no documents route yet", nothing fails.
    - Way back, step one: dealer_os_sync.apply_action gained purchase.set
      {id, field, value} (through serve.apply_purchase_field, the SAME door
      the Mac's own page uses, factored out of the request handler so the
      whitelist and dropdown checks apply to both), purchase.override {id,
      field retail_estimate|collection_date, value} and purchase.comment
      {id, text}; who = the action's by.email, else "Dealer OS". Steps two
      and three (chips, cancel, check in, add by hand, DealerKit, layouts
      and views back) not built yet.
    - A REAL TEST FILE BUG FOUND: test_pricing.py's summary print and
      SystemExit sat in the MIDDLE of the file (line 4569 of 4723), so
      every check appended after it, including the whole "Dealer OS two
      way sync" section from earlier today and this session's own, had
      never run; the count only ever moved by the checks that happened to
      land above it. Moved to the end with a comment saying why; the true
      count is 1,135, all passing (four expectations in the never-run
      block needed correcting once they finally executed).
  - THE PURCHASES WAY BACK PROVEN LIVE, DOCUMENTS BLOCKED ON DEALER OS'S
    STORE, v3.11.3, 2026-09-03 (Steven relayed the Dealer OS session's four
    step list; Dealer OS v1.26.0 through v1.32.0 went live at 10:43 after
    Vercel had blocked the first production deploy overnight, found via
    `gh api .../commits/<sha>/status`, "Deployment was blocked"). Step one:
    a Video change made on the live Dealer OS purchases page (ND20NVZ, the
    Ford Puma) arrived as purchase.set seq 6 with Steven's email, the Mac
    pulled it within its 15 s cadence and applied it to purchase id 202 (the
    live Carwow row of the Puma's double sale, id 199 being the cancelled
    Motorway one), timeline row stamped with the email, and the Clear
    afterwards (seq 7) went the same way with the AUTOMATIC re push landing
    20 s later. One real fault found on the way: the first re push crawled
    because sync_documents tried all 917 files against a store answering
    500, one failed PUT each; now stops after MAX_CONSECUTIVE_FAILURES (3).
    Step two: the live documents route exists (`PUT /api/bidbrain/documents`
    is in Dealer OS main and deployed) but answers 500 "could not store the
    document" to a real JPEG as well as a probe, so Dealer OS's own
    saveFile (Supabase media bucket) is failing on the server; nothing
    uploaded, 917 files ready (189 scans, 728 photos, 12 to downsize).
    Step four: the step two spec is in DEALER_OS_PUSH.md. Tagged v3.11.3.
  - THE PURCHASES WAY BACK, STEP TWO, BUILT ON THE MAC SIDE, v3.12,
    2026-09-03 (Dealer OS PR 59 / v1.33.0 has the Dealer OS half). serve.py
    gained apply_purchase_action(kind, payload, who), reached from the sync
    thread through a socketless subclass of CockpitHandler (_Doors(), built
    lazily since the class sits further down the file), so purchase.chip,
    purchase.cancel, purchase.checkin, purchase.add, purchase.dk, views.save
    and views.delete go through the EXACT handler methods the Mac's own
    page uses (_record_chip, _cancel_purchase, _check_in_purchase,
    _add_manual_purchase, _start_run_api for the DealerKit row jobs), each
    now taking who= (defaulting to the dealership name as before) and
    cancel taking source= ("dealer_os"). save_view_action/delete_view_action
    upsert or drop one named view through the page's own validators;
    _validate_purchase_views accepts purchase_columns as an alias of
    columns (Dealer OS sends the layout under that name). dealer_os_sync
    routes the seven kinds to a purchase_action hook; a ValueError is
    logged as "<kind> refused: <reason>" and skipped. Proven against the
    REAL database with a throwaway hand added car (TE57BBX: added with a
    new supplier, chipped 150 to a car price of 850, a negative chip
    refused, checked in, an unknown DealerKit action refused, a real car
    with no mileage refused for a DealerKit push with the standing
    "never guess the mileage" wording, an unconfigured cancel reason and an
    empty reason text both refused, then cancelled with source dealer_os;
    a view saved and deleted), every row and the supplier removed
    afterwards, walked_away cleaned of the test reg. NOT proven from the
    live Dealer OS page: Vercel blocked the v1.33.0 production deploy the
    same way it blocked v1.26.0 the night before ("Deployment was
    blocked" on 3a2c61a), so the live site still runs the older route and
    the document upload still answers the OLD generic 500 with no step or
    detail. 1,150 tests pass.
  - STEP TWO PROVEN LIVE END TO END, DOCUMENTS ALL ACROSS, v3.12.1,
    2026-09-03 (after Mark pushed Dealer OS v1.33.0 live at 10:43, then a
    further deploy at 17:26 that fixed the document store: the route had
    been handing the bucket a file with no type). Documents: PUT of a real
    JPEG answered 200 with a lasting cardealeros.co.uk/media link; the
    full backfill sent 917 files (189 scans, 728 photos, 3 downsized, 0
    failed) in about 8 minutes, then pushed the links. The six live tests
    on the real Dealer OS purchases page, each read back on the Mac (feed
    seq, cursor, purchase row, timeline row with Steven's email): add by
    hand (seq 8, purchase 230), chip (seq 9, car price 1000 to 850),
    check in (seq 10, In stock; the Dealer OS button uses a native
    confirm, stubbed in the tool), DealerKit check (seq 11 on the test
    car, which FAILED on the Mac because DealerKit's saved login had
    expired, visible only in manual_run.log; re-run on the real FX68KAE
    after auto_login_dealerkit.py, seq 15, dk_check refreshed with 1
    document), views.save (seq 12, "Live proof view" landing in
    purchase_views with the page's own keys and the column layout as
    columns), cancel (seq 13, source dealer_os, reg suppressed), views
    .delete (seq 14). One real slowness found: the loop's re push runs
    the document upload first (150 files a push), so during the backfill
    an applied action took minutes, not seconds, to echo back; normal
    now the backlog is gone. Fix shipped: purchases_run._dealerkit_with_
    relogin runs auto_login_dealerkit.py once on DealerkitSessionExpired
    and retries, and both row jobs write their outcome (or failure) to
    the timeline as a dealerkit event by BidBrain. Test car, its events,
    supplier, walked_away row and test view all removed. 1,151 tests.
  - THE SETTINGS WAY BACK, STEP THREE, v3.13, 2026-09-03 (round C of the
    Dealer OS session's list). serve.py: BUYING_RULE_KEYS (the day to day
    rules Dealer OS may send back; theme_*, fee bands, engine bans, run
    times, logins and hidden_connections stay on the Mac, reasons in
    DEALER_OS_PUSH.md), SETTINGS_SECTIONS, settings_section_clean(section,
    value, current) (pure: the purchases page's own three validators, the
    Settings page's _validate_settings on a per platform merge for
    buying_rules, _validate_integrations) and apply_settings_section
    (purchase sections through _Doors()._purchase_page_settings so the
    "still on N cars, retire it instead" guard applies). dealer_os_sync:
    settings.set with a "section" routes to a settings_section hook with
    newest wins per key via db.setting_updated_at; the older {key, value}
    shape still works. Push: dealer_os_push.mac_settings_block (buying
    rules, integrations, hidden_connections, fee_bands, banned_engine_names,
    run_schedule read from the launchd plists via daily_run._run_schedule,
    editable_sections, per key updated_at). One real bug caught by the
    tests before shipping: the per platform merge first replaced a
    platform's whole gate with the partial patch, so the validator then
    found max_reserve missing. Not proven from the live Dealer OS page
    (that side is not built yet); proven through the same validators with
    23 new checks. 1,170 tests pass.
  - STEP THREE PROVEN LIVE, ONE STAMP BUG FIXED, v3.13.1, 2026-09-04 (Dealer
    OS v2.14.1, the BidBrain Settings page, live from 09:00). All six saves
    from the live page reached the Mac with Steven's email and came back in
    the push (feed seq 16 to 21, then the six reverts 22 to 27): a Video
    answer renamed, a column width, the journey video stage, flat_spread,
    Motorway's gate (Dealer OS sends the whole buying_rules block, all
    platforms, not one alone; the Mac merges per platform regardless), and
    the whole integrations map (Glass's shows "hidden on the Mac Studio"
    there, so DealerKit stock was flipped instead). The two Status refusals
    never reach the Mac: Dealer OS greys Remove for an answer cars carry and
    shows the Mac's own "Status needs at least one option that means
    finished" sentence before sending; the Mac's own sentences were captured
    directly instead. THE BUG: apply_settings_section stamped its save with
    the Mac's clock, so seq 20 (the gate, sent 4 s after seq 19, the spread)
    was skipped by newest wins as "the Mac saved flat_spread more recently";
    now stamped with the action's own at (hook passes it through, _purchase_
    page_settings gained updated_at=), flat_spread's stamp corrected by hand
    and seq 20 replayed. The old {key, value} shape re-proven alongside.
  - PLATFORM GATE ALONE PROVEN, THE MAC'S OWN SCREEN RE CHECKED, AND A REAL
    WATCHLIST BUG FOUND, v3.13.2, 2026-09-04 (Dealer OS v2.14.2, PR 87).
    (1) Changing Carwow's max mileage alone on the live Settings page sent
    seq 28 carrying ONLY {"platforms": {"carwow": {...}}}, applied on the
    Mac leaving the other four platforms untouched, and came back in the
    push; reverted (seq 29). Worth knowing: a plain reload of the Dealer OS
    Settings page served a CACHED render showing the old value; a fresh
    address (?r=1) showed the new one, so check with a fresh address before
    believing a value did not arrive. (2) The Mac's own screens: all six
    pages serve 200, the cockpit rendered 98 cards with no page errors, and
    search, the platform tabs, star and unstar, the More drawer and Details
    all worked; every launchd job still loaded. (3) THE BUG, found because
    that star test turned the light red: motorway.set_watchlist clicked,
    waited 1.2 s, reloaded and read aria-checked, but Motorway keeps
    showing the old state for several seconds, so a click that HAD worked
    was reported as a failure ("still shows shortlisted=False after trying
    to set it to True"), the row sat pending and the light went red; worse,
    the retry then clicked again and genuinely left the car starred on the
    real account. Proven by reading the live page: the car WAS starred.
    Both setters (Motorway and Carwow, same shape) now poll for the star's
    own state to change, then confirm with a reload, retrying the read up
    to six times. Proven live end to end afterwards: star through the real
    /api/bid endpoint then sync = "starred on Motorway, 1 of 1 synced",
    unstar then sync = clean, and a final live read confirmed the car is
    NOT starred, so Steven's account is exactly as it was. New
    DEALER_OS_HANDOVER.md lists every action Dealer OS sends and the
    version each was proven on. 1,170 tests pass.
  - THE LAST SIX ACTIONS PROVEN LIVE, AND TWO REAL REPORTING FAULTS FIXED,
    v3.13.3, 2026-09-04 (Dealer OS v2.18.1, which also fixed the stale
    Settings render found the same day). Steven clicked all six on one real
    car, FX68KAE (a Carwow Ford Focus, DealerKit vehicle 247), feed seq 30
    to 38, every one arriving with his email and applying: purchase.override
    collection_date (first as a CLEAR, seq 30, which correctly did nothing
    and logged nothing because that car had no hand set date, a genuine
    "success on screen, no change on the Mac" case), retail_estimate 8999 to
    8995 (seq 31), collection_date 2026-09-14 (seq 32), purchase.comment
    (33), then purchase.dk price (34), photos (35, 4 real images), documents
    (36, 13 real documents), retail (37) and due_in (38, a real 14 September
    written to DealerKit; Steven chose to leave it).
    - FAULT 1, the worst: purchase.dk retail did NOTHING and said so
      wrongly. push_purchase deliberately never overwrote a retail price
      DealerKit already had, so BidBrain's own 8995 never reached DealerKit's
      9799, while the click reported success and the log said "BidBrain had
      no retail_estimate for this car yet", which was plainly false. Steven's
      call: "if we change it in dealer os it should be able to push that to
      dealerkit to match". Now two halves: the all in one push still leaves an
      existing DealerKit retail price alone, but an EXPLICIT item request
      (items names "retail") overwrites. Proven live straight after: "Retail
      price changed from £9799 to £8995", DealerKit read back 8995.
    - FAULT 2: every push item now reports what it actually did (set,
      changed from X to Y, already right, nothing to send) through a new
      result["outcomes"], printed per item and written to the car's timeline,
      replacing "Sent to DealerKit: price" for a price that merely already
      matched. The stale "added photos/documents to that record" and the two
      always-printed "No retail price / No due in date set" lines are gone.
    - Also noted for the record: a due in date on DealerKit can be changed
      but has never been proven clearable, so that button is one way.
  - DEALERKIT FROM DEALER OS, SENT WHOLE THEN KEPT IN STEP, v3.14, 2026-09-05
    (the Dealer OS session's six item list for its redesigned DealerKit tab,
    relayed by Steven; all additive, schema still 1). New way of working: a
    car is not sent to DealerKit until its collection date is confirmed;
    Send adds it with everything (purchase.dk_send: record, mileage as a
    real mileage log entry, price, buyer's fee as the Buyers Premium line,
    photos, documents, retail, due in date); afterwards only a chip, the
    transport invoice (Delivery Charge line) and a check in follow, and a
    cancelled sale is removed; purchase.delete removes a hand added car
    from the list (timeline kept, one row of kind deleted). db: six stamps
    (dk_mileage/fee/chip/transport_pushed_at, dk_checked_in_at,
    dk_removed_at), stamp_dk/clear_dk_stamp/on_dealerkit. dealerkit_write:
    add_mileage_log_entry, set_expense_items (delete the line client side,
    re add, Save & Approve, verify), check_in_vehicle, remove_vehicle_
    record, push_followup, SEND_ITEMS/FOLLOWUP_ACTIONS; push_purchase gained
    "mileage" and "fee". serve: dk_send_preflight (seven sentences, all
    returned together), dk_followup_refusal, the two new kinds routed by
    dealer_os_sync, _record_chip clears the chip stamp, a transport_fee edit
    clears the transport stamp, purchase.checkin triggers the DealerKit half
    best effort. daily_run.dealerkit_purchases_pass rebuilt (sends whole by
    the same preflight, then chips/transport/check ins/removals) and a real
    launchd job com.bidbrain.dealerkit-purchases at 16:15.
    - FOUR LIVE ROUNDS on a fake car (S17OUG, added by hand, DealerKit
      vehicles 320 to 325, all deleted after, zero traces on the Mac): every
      refusal sentence; send whole; a repeat send with "already right"
      outcomes; chip (68000 to 66000); transport by the AUTOMATIC run;
      cancel, Remove now, delete; then send, check in (DealerKit followed,
      Due In to In Stock, arrival date written), delete refused, remove of an
      In Stock car left in place with its sentence.
    - THREE REAL FINDINGS: (1) DealerKit names the expense category group
      differently per account, "Stock Purchase" (Mark) vs "Cost of
      Purchase" (Steven), the four line names are the same; both tried. (2)
      DealerKit HIDES its CHECK-IN button once a due in date is set and
      moves the car to In Stock by itself when that date arrives; a check
      in on a car sent whole is therefore written as the arrival date on
      Due In / In-stock On (round three failed on the missing button, round
      four proved the fix). (3) A removal is keyed by registration: the
      Puma (ND20NVZ, sold on both platforms the same day) would have lost
      its LIVE sale's record; refused by a guard in the door and the run.
      Also: a repeat send doubled the photos (3 to 6), now left alone when
      DealerKit already holds as many. 1,205 tests pass, the real database
      hashed before and after the suite.
    - v3.14.1, same day: CI had been red since v3.13.3 (found on the v3.14
      push). Three settings sync checks used the default database path, so
      they read the Mac's REAL stamps (green here, red on CI's empty
      database) and one WROTE details_style with a 2099 stamp into Steven's
      live settings, which would have made every later Dealer OS change to
      it "older" for ever. Checks moved to a temp database; the real row re
      stamped as saved today (value popup, Steven's own; no backup held the
      genuine stamp). The lesson already on record (hash the real database
      around the suite) now also reads: every test touching settings, bids,
      hides or views passes its own path.
    - v3.14.2, same day, two corrections from Steven on seeing the v3.14
      report: (1) the mileage is typed into the Mileage box BESIDE the
      registration on DealerKit's own "Add Vehicle To Stock" form (input
      placeholder "e.g. 43000 (Optional)"), verified via the API after the
      create, so the record never carries DealerKit's guess; the mileage
      log entry stays only as the fallback for an existing record with a
      different figure. (2) The due in date is NO LONGER SENT to DealerKit
      at all (removed from SEND_ITEMS and the automatic run; the Dealer OS
      "due_in" button is refused with a sentence): a date makes DealerKit
      hide its CHECK-IN button and check the car in by its own timer, and
      Steven wants the check in to be the person's decision, mirrored
      through DealerKit's own CHECK-IN form. set_delivery_date is kept
      only for check_in_vehicle's fallback on a record that already has a
      date. The send half was proven in a fifth and sixth live round on
      S17OUG (mileage typed at creation, delivery_on None, retail set). The
      check in half then failed three rounds running with "the CHECK-IN
      button was not found" on ANY record carrying a photo, including the
      real FX68KAE (vehicle 247), and a bisect pinned the photo upload as
      the trigger, not the image bytes (a byte identical copy of a hand
      uploaded photo hung too), not timing, viewport width or stage. The
      cause, read from DealerKit's own page script: the overview's lower
      half (the Due In banner whose Check-in button opens the "Check-in /
      Move" modal, Vehicle Details, Financials) sits behind a loader that
      only clears when an intersection observer fires, so it renders only
      once it SCROLLS INTO VIEW; a photo pushes it below the fold and the
      automation never scrolled. A person never sees this, and no record
      was ever broken. Also learned there: that button's own save writes
      delivery_on, so DealerKit's check in and its Due In date are one
      action. check_in_vehicle now scrolls like a person until "Vehicle
      Details" appears and clicks the banner button through Playwright (a
      coordinate click just below the viewport lands nowhere). Proven
      directly on vehicle 334 and then end to end through the Dealer OS
      door (purchase.checkin on the test row, seventh round, vehicle 335
      with a photo): Due In to In Stock, arrival day and mileage 37000 on
      the record, the timeline reading "checked in on DealerKit, Due In to
      In Stock". All traces removed (vehicle 335, row 238, its events).
    - v3.14.3, same day (Steven, via the Dealer OS session): the automatic
      16:15 DealerKit run NEVER sends a car whole any more, a car goes to
      DealerKit only when a person presses Send in Dealer OS
      (purchase.dk_send); daily_run.dealerkit_purchases_pass lost its send
      half and keeps only the follow ups (chip, transport, checkin, remove)
      on cars DealerKit already has. Also answered for the contract: the
      Send preflight's "no collection date yet" reads collection_date (the
      effective due in date, an override counts) and never
      collection_arranged, so Dealer OS matches that. Contract pushed to
      Dealer OS main again.
    - v3.14.4, same day (Steven, via the Dealer OS session): retail follows
      an edit. Dealer OS sends purchase.dk retail the moment a retail
      estimate is edited on a car on DealerKit; that explicit job already
      overwrote DealerKit's figure since v3.13.3 (confirmed in code, now in
      the contract). New: the automatic run also sends retail on any car on
      DealerKit whose stored dk_check.retail_price is missing or differs
      from retail_estimate, then updates the stored dk_check so it is not
      resent daily (a car with no dk_check at all is left alone). SCOPED
      to the working list before shipping: a first dry count showed the
      plain "differs" rule would overwrite 68 real DealerKit records,
      mostly finished or sold cars whose retail the team set by hand, with
      unrounded estimates (7541, 9207...); a finished (In stock) or checked
      in car is now never touched by the run, leaving 18 real working list
      cars that will go across on the first 16:15 run, FX68KAE included
      (9799 on DealerKit, 8995 on BidBrain, the figure Steven himself
      chose at v3.13.3). Not run live here; reported to Steven with the
      list so he can stop it before 16:15 if he disagrees. Contract
      pushed to Dealer OS main.

- 2026-09-16, backlog carried forward so nothing is lost between sessions
  (written down rather than kept only in a chat task list): private
  plates, a re-registered car should stop reading as missing on
  DealerKit; the dual auction win, the same car won on both Motorway and
  Carwow on the same day, needs a real answer; a fresh Motorway sign in
  is needed on the Mac, then the one off check that the CSV export still
  comes back; DealerKit webhook signature verification is still open;
  GX18JPF's Carwow photos were never captured, worth a look if it comes
  up again; the Watchlist sync once showed 0 of 5 synced and was not
  chased further; the Cobalt theme is built and waiting on Steven's own
  click to switch it on; the Mac's own stock badge and top picks still
  match models the old way, not the newer method; and the collection
  pivot and run planning idea (seller location on the purchases page,
  Nearby now, confirmed with seller, no show) has not been started.
