# BidBrain project status, part 19: v3.3, the sync light, the sweep, Dealer OS push step one, the review (27 August to 2 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.

  - v3.3 RELEASED, RECONCILING TWO PARALLEL SESSIONS' WORK ON THE SAME
    PAGE, 2026-08-27 (Steven: "ok lets cut a release"). This local copy
    had been sitting 10 commits behind `origin/main`, uncommitted, since
    the whole purchases module rebuild above started: Mark had pushed
    v3.1.4 through v3.2.3 in parallel (Under Offer, shortlist/watchlist
    sync, the Auc Status Bid stage fix, the Motorway won-date fix), and
    several of his commits touched the EXACT SAME files, `render.py`'s
    purchases page above all. Checked the overlap file by file before
    touching anything (`git diff --stat main origin/main`) rather than
    trust a blind pull: real collision, not just proximity, one commit
    was literally titled "a purchases page status fix".
    The uncommitted work was snapshotted (a branch plus `git stash push`)
    before anything moved, `origin/main` fast-forwarded in clean, then
    the stash reapplied on top so any conflict surfaced for a real
    decision rather than an autopilot merge choosing a side. Three files
    conflicted for real (`db.py`, `serve.py`, `render.py`); `daily_run.py`,
    `purchases_run.py`, `test_pricing.py` and `CLAUDE.md` (a genuinely
    append-only file, and none the worse for it) all merged clean.
    Every real conflict was additive, not competing: Mark's
    `collection_date_override` column sat right next to the rebuild's
    own new working-list columns in the same migration function; his
    `/api/set-collection-date` route sat next to the rebuild's whole new
    handler block; his `_dueInCell`/`viewLink` features and the rebuild's
    own column-driven, editable, drag-and-resize row all wanted the same
    `<tr>`. Kept both sides in full every time, never picked a winner.
    TWO REAL BUGS were caught only because the auto-merged (no conflict
    marker) two thirds of the diff was checked by hand rather than
    trusted on the strength of a clean `git stash pop`:
    (1) Mark's own new Platform-logo feature (`platCell`, "under the
    platform colume use the auction house logos") was fully built and
    fully unused, silently orphaned: it sat right next to the row
    builder but the rebuild's own `activeCols.map` still built the old
    plain text pill directly, no error, just a real feature that would
    have shipped invisible. Wired `platCell` into the platform cell for
    real, and gave its own no-logo fallback the lowercased platform
    class back (`.plat.manual` and friends had nothing to attach to
    otherwise).
    (2) Mark's `_dueInCell` (a whole editable Due in date feature, pencil
    button, save, clear, an "M" override badge) referenced `dueInView`,
    a plain toggle the SAME DAY's rebuild had already deleted in favour
    of the four-view system (`currentView`). This would have thrown a
    live `ReferenceError` on the very first row with no collection date,
    a genuine ship-breaking bug invisible to both `py_compile` and the
    test suite, only found by tracing every reference of every function
    either side had written rather than assuming a clean textual merge
    meant a working one. Fixed to read `currentView === 'duein'`.
    Verified after every hand resolution, not just at the end: `py_
    compile` on all seven touched files, the FULL merged test suite
    (660, both bodies of tests together, 0 failed), every touched page
    (`render_purchases`/`render_under_offer`/`render_settings`) rendered
    with zero leftover template tokens, the purchases page's own script
    extracted and `node --check`ed, the live server restarted (a genuine
    PID change confirmed, not just a curl 200) and the real page
    screenshotted showing BOTH bodies of work at once: the Under Offer
    nav tab, the real Motorway/Carwow logo pills in the Platform column,
    and the rebuild's own drag-resizable, editable columns, all on
    screen together. One live console error chased down and ruled a
    false alarm from stale buffered history in a long-reused browser
    tab, confirmed by attaching a fresh error listener immediately after
    a cache-busted navigation and finding it catch nothing.
    LESSON for any future session finding local main behind a shared
    remote with real uncommitted work sitting on it: never assume a
    clean, marker-free auto-merge is a correct one on a page more than
    one session has touched the same day. Read every function the OTHER
    side introduced, and check whether the surviving code still calls
    it, not just whether it still compiles.
    Shipped as v3.3 (README's Roadmap "Path to v3.3" bumped to "Path to
    v3.4" to make way, its own goal unchanged, matching the project's own
    "carries over" convention; the now-shipped "Shortlist watchlist
    sync" bullet removed from README's Next up list, Mark having already
    built it as v3.2.1). 660 tests pass.

  - A SYNC LIGHT IN EVERY NAV BAR, AND THE CRASHED RUN THAT HAUNTED THE APP
    FOREVER, 2026-08-31 (Steven, after asking what each sync actually shows on
    screen today: "i think they are a bit all over the place at the moment...
    i think the watchlist sync looks totally different to a motorway sync",
    then "i want to make it so no matter where you are or what page you are
    on you can see a sync is happening and an animation showing its still
    running with a traffic light system for easy to understand quick
    glances"). Audited every runnable thing first rather than guessed, and
    he was right, there were three unrelated families:
    (1) five actions (Run now, Glass's, DealerKit stock, DealerKit purchases,
    LE Capital) painted the cockpit's own progress bar; (2) three (CompMatch,
    watchlist, under offer) called closeRunModal() FIRST and then set a
    "Syncing..." label on a button now behind a shut dialog, so after their
    six second toast expired they were completely invisible while still
    running; (3) purchases and lost bids each had their own separate thing.
    Nothing was visible from another page at all, and a failure vanished the
    moment you navigated. Auction4Cars had been failing since the previous
    day with 98 of its cars still on the list and nothing on screen saying so.
    - REJECTED FIRST DESIGN, and the rejection was right. The first mockup
      put a labelled chip ("Motorway, reading page 3") in the nav bar. Steven:
      "those designs show that it is out of line with everything else in the
      nav bar at certain points, I absolutely dont want this at all, it all
      needs to fit". A chip carries text, so its width changes with its
      state, and a variable width flex child shoves the search box and every
      icon along with it. Not tunable, structural. Rebuilt around a FIXED
      footprint: the state lives inside the 34x34 button that is already
      there, and only the glyph and its colour change. Proven live rather
      than asserted, by forcing all six states through the real paint
      function and measuring the whole right hand cluster each time: one
      distinct layout, every state 34x34, nothing moves a pixel.
    - The button is not new on four of the five pages. The cockpit's #runbtn
      and purchases/Didn't win/under offer's #syncbtn are already the same
      34x34 control with a byte identical refresh arc icon; the light just
      colours whichever one is there. Only settings had none (it gains one,
      deep linking to cockpit.html?openrun=1 the way the bell's own notes
      already do). Clicks stay page native throughout, so the purchases sync
      modal and the cockpit run modal both still open from their own buttons.
    - THE ROLLUP is serve._sync_light(flags, progress, now, run_status, ctx,
      busy_reason), deliberately PURE, data in and a dict out, never a
      database path: db.py's own functions default their path argument at
      DEFINITION time, so a helper that read for itself could not be tested
      against a temp database without silently hitting the real one. That
      trap is on record in this file and has now caught this project three
      times. Returns login | active | stalled | failed | ok plus a label, in
      that precedence order. Shipped on /api/run-status (not the on demand
      /api/run-summary, which the modal reads once when it opens): the light
      has to be polled, so the two settings reads it needs sit behind a three
      second TTL memo, invalidated wherever daily_run's own settings cache
      already is, leaving the live half (proc flags, the progress file) read
      fresh on every tick. The whole computation is wrapped, falling back to
      ok, because a light must never be what 500s the endpoint the run bar,
      every page's poller and the modal all depend on.
    - THE BIG FIND, and the honest answer to Steven's "is there anything else
      that has been missed here?": a crashed background run haunted the app
      forever. purchases_run.py's __main__ had no exception wrapper,
      lost_bids_run.py had none at all, and launchd run detection was one
      line with no freshness gate (active = s.running || ACTIVE_PHASES
      .includes(p.phase)). A launchd run that died mid "reading" left an
      active phase in run_progress.json permanently: every cockpit load
      after it showed a live progress bar and polled every three seconds
      indefinitely, decorated only with "no update for 47231s". The light
      would have inherited this as eternal amber. Fixed at both ends, a ten
      minute dead run cutoff in _sync_light and the identical one in
      renderProgress, plus real exception wrappers on all three scripts.
    - Three smaller truths fixed with it, each a real gap not a tidy up:
      purchases_run.main() wrote _progress("done", "Updated...") and THEN
      sys.exit(1) when a platform could not be read, so the screen said
      success while the exit code said failure and nothing durable recorded
      either; purchases and lost_bids never wrote run_status at all, so a
      failed 15:45 or 15:55 launchd sync left no trace anywhere; and
      share_comps.py and lost_bids_run.py wrote no progress at all (the two
      runnable things that did not), so a light built on that file alone would have shown
      nothing while CompMatch was genuinely mid sync.
    - setRunning gave up ownership of the Run button. It had always
      unconditionally rewritten b.title, which quietly destroyed the failure
      tooltip baked in at render time (the red dot only ever survived
      because it was a class, exactly as _button_flag's own docstring says).
      Two owners of one button is the bug; setRunning now keeps disabled
      alone and the light owns the glyph, colour and title. The render time
      red dot is retired with it: it was stamped in at render time so it was
      stale a minute later, it only ever showed on the cockpit, and it read
      failures by matching English notice text (which is why a dead Cazana
      session, the worst real fault, reddened nothing). The light reads the
      durable run records instead. The bell is untouched and still lists
      every notice in full.
    - TWO REAL DEFECTS CAUGHT LIVE, neither by review: (1) the light painted
      its class correctly and showed GREY. `.controls .runbtn:disabled` sets
      color on the BUTTON at a specificity no plain state class can beat, and
      a pass in progress disables its own button, so the state was there and
      invisible. Fixed by setting the colour on the SVG instead: a direct
      declaration beats an inherited one whatever the specificity, which
      settles it with no !important and no selector arms race. (2) An idle
      light sent the label "Run now", which renamed the purchases, Didn't win
      and under offer buttons from their own "Sync now" the first time they
      went idle. An idle light now says nothing and each page keeps its own
      wording. A third, caught by reading rather than running: only the
      cockpit defines --g1 and --g3 (the grade circle tokens the progress bar
      reaches for), so a shared block written against those painted nothing
      at all on the other four pages. The block now depends on --good and
      --no, which all five have, and carries its own amber.
    - Verified live end to end against a REAL 17:10 launchd daily run in
      flight, which is the feature's whole point: green and spinning with the
      genuine live message ("Motorway: valued 63 of 102: MD68RJO Volkswagen
      Golf SE Navigation TSI") simultaneously on purchases, Didn't win, under
      offer and settings, none of which started it and three of which could
      never have shown it before. On the cockpit, confirmed the button reads
      disabled grey while its icon reads green, the detail bar still showing
      underneath it (kept deliberately, Steven's own decision: light for the
      glance, bar for watching). The full lifecycle was proven with a stubbed
      response rather than by waiting: active, then a green tick reading
      "Finished just now", then back to idle with the button's own "Sync now"
      restored. 969 tests pass (44 new: the _sync_light rollup, the run_status round trip, _finish, _age_words, and the export count).

  - THE FULL SWEEP OF THE SYNC LIGHT WORK: 73 CONFIRMED FINDINGS, EVERY ONE
    FIXED OR RULED, 2026-09-01 (Steven: "there are countless bugs/issues in
    what you have just built, the whole app is supposed to be consistent and
    its not. i want you to do a full sweep and catch every error that you
    have made in the implementation of the last few commits"). He was right.
    Run as a six dimension multi agent audit (server rollup, runner exits,
    front end JS, cross page consistency, the Run modal plus the last three
    commits, docs and truth), every finding then adversarially verified
    against the real files before anything was touched; 73 confirmed, 1
    refuted, deduplicating to roughly 35 distinct defects. The worst, in
    order of harm:
    - EVERY COMPLETED DAILY RUN ENDED AS "FAILED". run()'s last progress
      write was the loop's own per platform partial done and nothing ever
      wrote a real terminal one, so the light read every finished run as
      active for ten minutes and then "stopped without finishing" forever,
      red on all five pages until the next day's sync overwrote the file,
      while the Run modal's own Last run card said Finished from
      run_status: three indicators, three answers. run() now ends through
      _finish("run", ...), which also covers the everything-off-in-Settings
      run that stranded an active "stock" phase the same way.
    - STARTING ANY PASS SHOWED THE PREVIOUS PASS'S LEFTOVER AS A LIVE LIE:
      rule 2 trusted whatever run_progress.json still held, so clicking
      Sync now went amber with yesterday's "Updated. 216 shortlisted..."
      and "no update for 1214m" until the new pass's first write landed.
      Fixed at both ends: every spawn handler now primes the file with a
      "starting" stamp (a phase deliberately NOT in the active set, so a
      pass that dies before its first own write leaves nothing that walks
      active -> stalled -> failed), and the rollup only trusts the file's
      message and age when the phase plausibly belongs to the live pass,
      falling back to _busy_reason's own honest sentence otherwise.
    - THE THREE REBUILT MODAL CARDS DESTROYED THEMSELVES: the CompMatch,
      watchlist and under offer handlers still wrote btn.textContent from
      the v1 buttons, wiping both spans of the new two span cards so the
      age lookup returned null for the life of the page. They now write
      the age span and re read the board on finish, like the other four.
    - A STOP PAINTED A GREEN SUCCESS TICK ON EVERY PAGE, for a run the
      user had just deliberately killed: SIGTERM kills the child before
      any except handler, so nothing recorded the stop and the busy to ok
      transition read as a finish. serve._stop_run now records the stop
      durably for every pass it kills (a proc name to run_status key map,
      login and the per row re read deliberately absent), and the client
      tick is gated on a genuine non partial terminal done.
    - PER ROW PUSHES CRASHED UNWRAPPED and stranded an active phase, which
      held the whole app's light red, the exact opposite of what the old
      unwrapped code's own comment claimed the exemption prevented. All
      three per row flows now run through a wrapper that writes "error"
      with no run_status record, which is what actually keeps one row's
      problem on the page's own bar and off the idle light; push_dealerkit
      also gained the DealerkitSessionExpired catch its sibling always had.
    - "DONE." NEXT TO A RED LIGHT: all three list pages wrote the literal
      "Done." on finish regardless of outcome, adjacent to a light showing
      the failure. All three now branch on the real phase. The watchlist
      pass also recorded ok=True with "0 of 26 synced" when a dead session
      failed every single row, so stars silently never synced under a
      green light: zero synced with work pending is now a failure.
    - Plus, in brief: the passes whose failures were invisible to the
      light (they wrote phase "error" with no durable record: the bulk
      DealerKit check, the clickdealer pass) now record run_status like
      every sibling, which is what actually closes the red-bar-green-light
      gap — a bare error phase itself stays DELIBERATELY invisible to the
      light, see the verification round below; a crashed launchd purchases run made
      every cockpit load poll forever on an orphaned file (renderProgress
      gained the same 600s cutoff, its sticky red error bar also now lets
      go after ten minutes, a deliberate change to the 2026-06-09
      behaviour now the light and bell own stale failures); pollRun
      treated a mid run partial done as run completion (a false "Finished"
      flash plus reload mid run, now gated on !partial and on having
      genuinely watched a run, which also killed a ~1.2s reload loop the
      login gate fix would otherwise have introduced, the verifier caught
      that one in the PROPOSED fix); the modal reopened mid login wiped
      its own confirm and cancel buttons (repainted from _rmLoginSite);
      the board had no row at all for the purchases sync, Didn't win sync
      or bulk DealerKit check whose failures the light shows (three status
      link cards added, and both list pages' sync modals now show the
      failure with a link to the board, since a red light clicked there
      used to open a picker that pretended nothing was wrong); Under
      offer's light button launched a live platform read on a bare click,
      the only page of the five to do so (a confirm now, matching the
      app's own convention); the light's amber was the cockpit's --g3, a
      burnt orange nearly indistinguishable from its red at 18px (now
      #d39e00, the app's own established in progress amber) and its spin
      ran at .9s against the app wide .8s (unified, and the light is now
      the ONE owner of the motion, the page local .spinning adds removed);
      the light CSS is one shared constant spliced into both _frame_css
      and the settings page so the hand copy can never drift; slPaint
      mirrors its label onto aria-label (a static aria-label outranked
      title, so the cockpit and settings announced a fixed "Run now"
      whatever the light showed); slNudge() pokes the light the moment
      any pass starts or stops from any page instead of the idle 10s
      cadence; slButton and the add car pending() probe use plain
      getElementById (bbEl's missing element warning fired as noise on
      every load for ids absent by design); rmAge now matches _age_words
      word for word (the modal said "just now" where the tooltip said
      "50m ago"); _mins gained an hours tier; busy "reason" now returns
      from every refusal endpoint and is read by every modal action;
      run_status summaries get the same first line cleaning site_health
      always had; the integrations instant save now clears the light memo
      (the one settings writer that did not, so the light and modal
      disagreed for the TTL); the memo snapshot is one atomic tuple; the
      dead code went (the retired _button_flag machinery and its four
      never interpolated variables, the hasflag and runbtn.spinning CSS,
      the v1 modal's option list and coloured button CSS, rmToggleAll and
      the #rm-all probe that warned on every toggle); CompMatch with no
      DEALER_NAME set is now off, not broken (it recorded an uncleareable
      permanent failure for an opted out dealership); the run summary
      names only platforms genuinely read, with fell back platforms named
      separately (it used to claim "Read X" for a platform whose own row
      said the read failed); dealerkit_check_pass and clickdealer_pass
      now record run_status like every sibling, with real labels; and the
      four grep source text tests became behavioural ones against a new
      testable purchases_run._finish_sync.
    - FROM THE EARLIER MANUAL PURCHASE COMMIT, swept in the same pass: the
      duplicate reg guard blocked legitimately re buying a long since sold
      car (now scoped to the working list via the finished flagged stock
      statuses, mirroring the page's own view definition); "Add the car"
      with a typed but uncommitted supplier false alarmed "This page is
      broken ... Refresh" because the need guard did not know a dropdown
      mid add-new swaps its select for an input (the pending() net written
      for exactly that case sat below the guard and could never run); and
      the Supplier sort read r.supplier alone while the column displays
      the platform name for platform rows, dumping every platform row
      into the missing value bucket.
    - THE RECORD ITSELF WAS WRONG: every quote and decision in this
      feature's comments, tests and the entry above was attributed to MARK
      and dated 2026-09-01, but this is Steven's Mac, the requests were
      Steven's, and the work was done 2026-08-31, before midnight. On a
      shared two dealership repo whose decision log decides whose standing
      instruction a behaviour is, that is a real record error, not a nit:
      fixed at every occurrence across seven files plus the already
      committed manual purchase comments, with the two genuine 2026-09-01
      DATA values (a real funding expiry, a real collection date fixture)
      deliberately left alone. A stray file "ith" (an accidental 4.8KB
      shell redirect dump of daily_run.py) was deleted from the repo root.
    - Deliberately NOT changed: the settings page is still not in the
      orphan class scan (adding it surfaces a backlog of pre existing
      unscanned classes, its own piece of work, and the shared CSS
      constant now prevents the drift the scan would have caught); and
      the sync modals were not restyled to the new board design, they
      gained the failure note only. 987 tests pass after the verification
      round below (18 more than before the sweep, every new one pinning a
      fixed behaviour).

  - THE SWEEP'S OWN VERIFICATION ROUND CAUGHT THE SWEEPER, 2026-09-01,
    same session, before anything shipped. Once every fix above was in,
    three fresh independent reviewers re-audited the whole fix diff
    against the findings list; they confirmed the rest and caught 14
    distinct problems IN THE FIXES, the three worst being genuinely
    structural:
    - THE ERROR PHASE RULE INVERTED THE PER ROW DESIGN. An added rollup
      branch turned ANY idle "error" phase red app-wide, directly
      contradicting the same sweep's own per row decision three files
      away ("one row's failure lives on the purchases page's own bar,
      never on the app-wide idle light") and painting the five
      off-in-Settings refusal writes as standing app-wide failures,
      violating "off is not broken". Removed outright rather than
      filtered: every bulk pass now records run_status on failure, so the
      idle scan already covers every real failure with a proper label and
      a per key clear, and the only unrecorded error writers left are
      exactly the two kinds that must stay page local. The test that had
      pinned the wrong behaviour was replaced with five pinning the right
      one.
    - STOPPING A SINGLE ROW'S PUSH RECORDED A BULK FAILURE. The per row
      DealerKit push and check reuse the bulk passes' own proc slots, so
      Stop's new recording misattributed a one row abort as the bulk pass
      failing, red app-wide until a bulk success that might never come. A
      _row_procs set now marks what kind of work each shared slot holds
      at spawn time, and Stop skips the record for row work. A deliberate
      Stop also no longer wears the word "failed": _STOP_SUMMARY is one
      module constant written by _stop_run and recognised by the rollup,
      so the light says "The daily run stopped · just now", never matched
      English.
    - THE CLIENT TRUSTED AN ORPHANED "starting" STAMP. The server's own
      rollup deliberately ignores a primed stamp once its proc has gone,
      but renderProgress counted it alive for 600 seconds, so a pass that
      exited before its first write (deterministically: CompMatch with no
      DEALER_NAME) left every cockpit load showing a false live bar with
      a disabled Run button for ten minutes. The client now requires a
      live proc flag for "starting", the same rule-2-versus-3 refinement
      the server already made.
    - And the smaller ones, all fixed: dealerkit_check missing from the
      off/hidden mapping (a stale failure could not be silenced by
      switching the DealerKit purchases toggle off or hiding the
      connection, now mapped through _RUN_KEY_INTEGRATION); share_comps
      --export still recorded the permanent opted out failure sync() had
      been fixed to avoid (same guard, both entry points); slNudge could
      permanently fork the poll loop when a nudge landed while a poll's
      fetch was in flight (an slInFlight guard queues one fast follow up
      instead); the fixed Add car guard still fired bbEl's broken page
      warning for the legitimate add-new state (both probes silent now,
      the alert is the signal); a dead .runmodal-opts rule sat directly
      above the comment claiming the dead rules were cleared, and the
      fully dead _categorize_notices unpack survived the _button_flag
      removal (both gone, with the two stale comments that described the
      retired spin and red dot mechanisms rewritten); one comment dated
      the sweep itself a day early and serve.py still dated the crashed
      run discovery 2026-09-01 against three siblings saying 2026-08-31
      (both aligned); CHANGELOG.md's committed v3.7 header was dated
      2026-09-01 for a tag cut 2026-08-31 (fixed); and a PRE EXISTING
      bug outside the sweep's own findings surfaced by the review: six
      purchases page filter drawer selects (Video, Person collecting,
      Finance, Logbook, Paid for, Paperwork) called applyView() on
      change, a function that exists only on the cockpit, so changing
      any of them threw a silent ReferenceError and the filter only
      applied through some other repaint; they call frameApply() now,
      like every other filter on that page. 987 tests pass.

  - BIDBRAIN INTO DEALER OS, STEP ONE: THE PUSH, 2026-09-01 (Steven, on
    his own copy, after reading two documents Mark's side prepared: a
    Dealer OS developer setup runbook and "BidBrain today, Dealer OS
    tomorrow", an architecture briefing whose "Open decisions" section
    is the real agenda; then Mark's own `DEALER_OS_CONTEXT.md`, a full
    sweep of the `CarDealer-OS/Dealer_OS` repo at v1.1.0, which answered most
    of those unknowns: Next.js 16 / Prisma / Postgres on Supabase with
    row level security, multi tenant, live at cardealeros.co.uk serving
    both dealerships, an empty "BidBrain" slot already in its Overview
    sidebar, a `Vehicle` model plus `/api/vehicles` with no UI, and NO
    auth surface of any kind for an outside service to post into).
    Steven: "i want you to work with me and do the work thats in this
    document to help me get bid brain inside dealer os", then, on the
    two choices put to him in plain English, "lets just make a password
    now" and "go with your recommendation" (send after every run, plus a
    button). From this session on Steven is to be spoken to as a total
    non technical person (his own instruction, saved to memory); every
    explanation here was given in kitchen and restaurant terms.
    - THE SHAPE, agreed: the Mac keeps every scraper and the whole
      buying brain; after every run it POSTs one JSON document of
      FINISHED, DECIDED results (shortlisted and held cars with price,
      reasons, flags, CompMatch's suggestion, the stock gap flag) to
      `{DEALER_OS_BASE_URL}/api/bidbrain/cars`, bearer authenticated
      with a shared secret. Dealer OS only displays it. Never raw
      listings, never the rules, never rejected cars (noise). Nothing
      flows back yet. The full field by field contract, and exactly
      what Dealer OS has to build (the route as a `PUBLIC_EXCEPTIONS`
      entry in its `src/proxy.ts` with its own constant time bearer
      check, a `BidBrainList` style tenant table, `{"received": n}`),
      is `DEALER_OS_PUSH.md` at the repo root, copied into
      `~/Dealer_OS/BIDBRAIN_PUSH_CONTRACT.md` for the separate Dealer OS
      Claude session Steven set up the same evening (its own working
      directory, `~/Dealer_OS`, a fresh clone with `npm install` done and
      Node 22 installed via fnm to match the repo).
    - THE SECRET: `openssl rand -base64 32`, written by the session and
      never printed into chat, to `dealer_config.py` (`DEALER_OS_BASE_URL
      = "https://www.cardealeros.co.uk"`, `DEALER_OS_API_KEY`) and to
      `~/Dealer_OS/.env.local` as `BIDBRAIN_API_KEY`, both confirmed
      gitignored and byte identical. Blank defaults added to
      `dealer_config.example.py`. Steven was warned that Mark's real
      `.env`/`.env.local` values must be ADDED to that file, not replace
      it, or the line is lost.
    - BUILT: `bidbrain/dealer_os_push.py` (stdlib urllib, `build_payload`,
      `push`, `DealerOsPushError` with plain worded messages for a wrong
      secret, a missing letterbox (404), an unreachable site, or blank
      settings). `daily_run.dealer_os_push_pass()` (`--dealer-os-push`),
      called by `run()` itself right after the final page write and
      BEFORE `_finish("run")`, `standalone=False` so it can never fail
      the run; three quiet non failure exits (toggle off, not configured,
      no saved run). Its own `INTEGRATION_DEFAULTS["dealer_os_push"]`,
      run_status key `dealer_os_push` and site_health key `dealer_os`,
      so the sync light and the Integrations tab both show a failed
      push. serve.py: `_dealer_os_proc`, `POST /api/push-dealer-os`,
      `dealer_os_running`, folded into `_any_proc_busy`/`_busy_reason`/
      `_stop_run`/`_STOPPED_RUN_KEYS`/`_RUN_KEY_SITE`/`_RUN_KEY_LABELS`;
      new `_HIDEABLE_SITES = _RELOGIN_SITES | {"dealer_os"}` because
      Dealer OS can be hidden but has no login to redo (`_apply_hide` and
      the hidden_connections normaliser now use it, `_RELOGIN_SITES`
      unchanged so a relogin request for it is still refused). render.py:
      a "Dealer OS" Run modal button (`dealerosbtn`, `data-check=
      "dealer_os_push"`, so `syncRunModal` already re syncs its on/off
      state), `runDealerOsPush`/`pollDealerOsPush`, and a Dealer OS row
      on the Integrations tab via `_NO_LOGIN_SITES` (badge "Auto, after
      every run", no relogin buttons, `_site_state` wording "list sent
      today").
    - PROVEN: 1,028 tests pass (33 new: the document's shape, held vs
      priced cars, the bearer header and route, every failure wording,
      the hide/off/light wiring, both pages' markup). A real
      `dealer_os_push_pass()` against a stand in letterbox on
      127.0.0.1 (a 20 line http.server checking the bearer) delivered
      the real last run and recorded ok on both keys. The genuine route
      does not exist on cardealeros.co.uk yet, so a real push today
      reads "Dealer OS has no letterbox at /api/bidbrain/cars yet", red
      on the Integrations row, by design, until the Dealer OS session
      builds it. NOT committed, left in the working tree for Steven's
      own release call (his copy also carries unrelated uncommitted
      render.py/UX_AUDIT.md changes that predate this session).
    - DEALER OS SIDE BUILT, AUTH SETTLED AS ONE TOKEN PER DEALER,
      2026-09-01/02. Steven's separate cloud Claude session on
      `CarDealer-OS/Dealer_OS` (branch `claude/dealer-os-overview-l82c83`,
      rebased on main v1.4.0, commits af505cf then b66c192, no PR yet
      pending Mark's PR vs direct push answer) built the route
      `POST /api/bidbrain/cars` (a `PUBLIC_EXCEPTIONS` entry in its
      `proxy.ts`, `timingSafeEqual` bearer check, token never logged),
      a `BidBrainList` table upserted per (dealerId, saleDate) with RLS
      via `prismaForDealer`, and `/admin/bidbrain` replacing Mark's own
      placeholder iframe to localhost:8765 (which only ever worked on
      the Mac Studio). Its first cut used ONE shared secret resolved by
      `dealer_name`; sent back (Steven, on this session's advice) and
      switched to per dealer tokens: Dealer OS env
      `BIDBRAIN_API_KEYS="<dealer-slug>=<token>,..."`, tenant resolved
      from the matching token, `dealer_name` a sanity check only, a
      mismatch still stored under the token's dealer and returned as a
      `warning` field in the 200 body. Replies: 200 / 401 empty / 400
      malformed / 503 keys unset. It also caught that its own
      `.env.example` placeholder would have been accepted as real keys.
      Unverified there (no database in the cloud): the upsert, the
      page, and `prisma migrate deploy` on production, all Mark's to
      run with Vercel access; deploys are manual (`npx vercel --prod`).
    - THIS SIDE, 2026-09-02: `dealer_os_push_pass` now surfaces that
      `warning` in its run_status summary (the Run modal card). Both
      tokens regenerated as `openssl rand -hex 32` (hex so they can
      never contain the `=`/`,` separators): Steven's into
      `dealer_config.py`, both labelled by dealership in the Mac
      Studio's `~/Dealer_OS/.env.local` with the real
      `BIDBRAIN_API_KEYS` line left COMMENTED OUT because the two
      dealer slugs (`Dealer.slug` in Dealer OS) are not known here and
      were deliberately not guessed; Mark supplies them, or fills the
      Vercel line himself from the two tokens. The old base64 secret
      is retired. `DEALER_OS_PUSH.md`'s Auth section updated to match.
      Still nothing committed on this side; end to end test still
      blocked on a reachable Dealer OS (see the memory file
      dealer-os-push-testing-reminder). Steven's text to Mark (asking
      for `.env` values and PR vs direct) sent 2026-09-01 evening.
  - THE REVIEW, STEP 1 AND STEP 2, 2026-09-01/02 (Steven: "do a review
    of bidbrain itself, what you think its good at, what is it not good
    at? how could we make it much better?", then "do step 1 now", then
    "do all 5"). The review's own headline finding was WRONG and Step 1
    caught it: "engine could not be confirmed" looked like the biggest
    rejection reason (2,502 in 3 days) but every one of those rows has a
    BLANK reg (5,004 of 5,004 in a fortnight), i.e. Auction4Cars cars
    that failed the cheap list gate and were never visited, so had no
    reg and no engine text, saved several times over. Nothing good was in
    that pile. The TRUE deduplicated rejection list is all deliberate
    bans (PureTech 489/week, EV+hybrid 354, Land Rover 199, vans 161...),
    Steven: "no changes to ban list, its there for a reason". The
    "missing owners/reserve" rejections (Carwow ~100/week each) turned
    out, on a live page read, to be list gate failers whose facts were on
    the page all along, never read because never visited; genuine
    "rejected for a missing fact alone" is 2 cars a week. The held pile
    (115 regs/fortnight, 84 Motorway) had three causes: Cazana's session
    dying mid run (3 runs, circuit breaker, everything after held and, on
    a one day auction, lost), the Kia "c'eed" spelling (Motorway) vs
    "Cee'd" (Cazana, confirmed live on two real regs) failing
    match_tokens, and 18 genuine no matches. Stars vs shortlist: 116 to
    364 shortlisted a day, 0 to 7 starred, the review's real finding.
    - STEP 2, all six built and proven: (1) daily_run `_cazana_relogin`
      runs `auto_login_cazana.py` as a subprocess once per run when the
      breaker trips, resets the counter and retries the same car
      (proven the script logs in headless right now). (2)
      glass.match_tokens strips punctuation both sides (plus the missing
      `import re` it needed). (3) db.save_assessments deletes today's
      rows for the batch's regs first, so one row per car per day; the
      history was de-duplicated by keeping each day's LAST save
      (47,879 to 20,309 rows, backup
      data/bidbrain.db.backup-20260902-005602-assessments-dedupe). (4)
      auction4cars.parse_listing sets engine at read time. (5) new
      daily_run._tidy_reasons drops "is missing" reasons from a
      rejection that also has a real one, never from one with nothing
      else. (6) NEW BAN, Steven: "1 engine to ban would be any ford 2.0
      diesel engine": BANNED_ENGINES "Ford 2.0 diesel", any year, and
      banned_engine gained a generic "fuel" rule key matched against
      the engine text OR the fuel field. 19 new tests, 1,047 pass.
      --reprice applied the ban to the saved run (see the changelog for
      the count). Committed and pushed as 56e2e67 (Steven: "ok commit
      it", "push it"), CI green, then released as v3.9 the same night
      (Steven: "ok cut v3.9 now", choosing to ship BidBrain's half of
      the Dealer OS push now, safe because blank settings mean it does
      nothing, rather than wait for the Dealer OS side to go live).
