# BidBrain → Dealer OS: the push contract

What BidBrain sends into Dealer OS, and what Dealer OS has to build to
receive it. Written 2026-09-01 as the agreed shape between the two
sides (see the "BidBrain today, Dealer OS tomorrow" briefing and
`DEALER_OS_CONTEXT.md`). Read this before building the Dealer OS side.

## The shape of the integration, in one paragraph

BidBrain stays on the dealer's Mac and keeps every scraper and the whole
buying brain (the hard gate, the bans, the pricing formula, CompMatch).
After every run it POSTs one JSON document containing its **finished,
decided results**, the shortlisted and held cars with their prices and
reasons, to Dealer OS. Dealer OS **only displays** that list on its own
BidBrain page. It never re-runs the gate or the pricing, never sees raw
listings, and never holds a copy of the buying rules, so there is exactly
one set of rules and they cannot drift.

## What BidBrain does (already built, `bidbrain/dealer_os_push.py`)

- **When**: automatically at the end of every daily run (scheduled or
  Run now), plus on demand from the cockpit's Run modal ("Dealer OS"
  button), which re sends the last saved run.
- **Where**: `POST {DEALER_OS_BASE_URL}/api/bidbrain/cars`
- **Auth**: `Authorization: Bearer <token>`, **one token per
  dealership** (settled 2026-09-02, Dealer OS's reply). Each Mac's
  `dealer_config.py` holds only its own token as `DEALER_OS_API_KEY`;
  Dealer OS holds them all as `BIDBRAIN_API_KEYS="<dealer-slug>=<token>,
  ..."` and resolves the tenant from whichever token matched. Tokens are
  `openssl rand -hex 32` (hex only, so they never clash with the `=` and
  `,` separators). `dealer_name` in the document is a sanity check only:
  a mismatch still stores the list under the token's dealer and comes
  back as a `warning` field in the `200` body, which BidBrain shows on
  its Run modal card. Never committed on either side.
- **Body**: `Content-Type: application/json` with
  `Content-Encoding: gzip`, the document below. Dealer OS decompresses
  it and applies its size limit to the decompressed document (its own
  issue #740, 2026-09-21). `gzip`, `x-gzip`, `deflate` and `br` are all
  accepted, and no header at all still means a plain body.
- **Retries**: none. A failed push is recorded on BidBrain's side (its
  nav bar light goes red, the Integrations tab shows why) and the next
  run, or the button, sends the whole list again. Every push is the
  complete current list, so Dealer OS can always treat the newest
  document for a `sale_date` as the truth and discard older ones.
- **Frequency**: one document per run. A dealer runs a handful of times
  a day at most; the document is a few hundred cars, well under 1 MB.

## The document

```json
{
  "schema_version": 1,
  "dealer_name": "Really Easy Car Credit",
  "sale_date": "2026-09-02",
  "generated_at": "2026-09-01T18:12:04",
  "shortlisted": 112,
  "held": 9,
  "cars": [ { ...one car record per shortlisted or held car... } ]
}
```

- `schema_version` only changes when a field is renamed or removed. A
  field being **added** never bumps it; ignore fields you do not know.
- `dealer_name` is how Dealer OS tells which tenant the list belongs to
  (each Mac is one dealership). It is BidBrain's `DEALER_NAME` from
  `dealer_config.py`, `null` if that dealer never set one. Map it to the
  `Dealer` tenant on your side; a name you do not recognise should be
  refused, not guessed.
- `sale_date` is the auction day the list is for (the next day's sale
  when read after 5pm), ISO `YYYY-MM-DD`.
- Rejected cars are deliberately not sent (over a thousand a day, never
  acted on).

### One car record

```json
{
  "reg": "PN68ZWX",
  "make": "Skoda",
  "model": "Kamiq",
  "derivative": "1.0 TSI SE L 5dr",
  "year": 2018,
  "mileage": 41200,
  "owners": 2,
  "grade": 2,
  "fuel": "Petrol",
  "transmission": "Manual",
  "engine": "1.0 TSI SE L Petrol",
  "body_type": "SUV",
  "service_history": "full",
  "vat_qualifying": false,
  "reserve": 6000,
  "cap_clean": 7250,
  "distance_miles": 42.0,
  "location": null,
  "platform": "Motorway",
  "listing_url": "https://pro.motorway.co.uk/vehicles/22660326",
  "photo_url": "https://...jpg",
  "auction_ends_at": null,
  "status": "shortlist",
  "reasons": [],
  "flags": ["Full service history."],
  "pricing": {
    "glass_retail": 9085,
    "cazana_retail": 11251,
    "calc_glass": 10448,
    "calc_cazana": 12939,
    "governing_value": 10448,
    "governing_source": "Glass's",
    "max_bid": 7448
  },
  "suggested_bid": 7100,
  "suggested_bid_comps": 12,
  "stock_level": "gap",
  "stock_count": 0
}
```

Field notes, the ones that are not obvious:

- Any field BidBrain could not read is `null`, never a guess (its golden
  rule 4). Do not fill one in on your side either.
- `reg` is uppercase, no spaces. It can be `null` (a listing with no
  plate); key on `listing_url` then.
- `status` is `"shortlist"` (priced, ready to bid on) or `"held"`
  (passed every rule but could not be priced, `pricing` is `null` and
  `reasons` says why).
- `reserve` is the platform's own reserve where it discloses one
  (Motorway, Auction4Cars); on Carwow it is CAP Clean standing in, on
  Dealer Auction it is Retail minus Margin, on DealerWay the current bid.
  Label it per platform the way BidBrain's own cards do if you show it.
- `pricing.max_bid` is **the number**: the most BidBrain recommends
  paying. `governing_value` is what the car retails for, and
  `governing_source` says which valuation decided it.
- `suggested_bid` is CompMatch's "what it would actually take to win"
  figure from the dealer's own won and lost history, `null` when there
  is no comparable history. `suggested_bid_comps` is how many real sales
  it is based on.
- `stock_level` is `"gap"` (none of this model in stock), `"ok"`,
  `"too_many"`, or `null` when no DMS stock snapshot exists.
- `flags` are short human sentences meant to be shown as is.
- `auction_ends_at` is an ISO datetime for the multi day platforms
  (Auction4Cars, Dealer Auction, DealerWay), `null` for Motorway and
  Carwow which close at a fixed daily time.

## What Dealer OS has to build

1. **The route** `POST /api/bidbrain/cars` under `src/app/api/`.
   - Must be a `PUBLIC_EXCEPTIONS`-style entry in `src/proxy.ts` for
     this exact path and method, since the caller is a machine, not a
     logged in Supabase user. Its own auth is the bearer check below.
   - Compare `Authorization: Bearer <token>` against `BIDBRAIN_API_KEY`
     from the environment with a constant time comparison. Missing or
     wrong: `401`, empty body. Never log the token.
   - Resolve `dealer_name` to a `Dealer`. Unknown: `422` with a short
     JSON reason. (Or, if you prefer one secret per dealer, ignore
     `dealer_name` and resolve the tenant from the token instead; either
     is fine, say which in the reply to this doc.)
   - Store the document. Suggested: one row per push in a
     `BidBrainList` table (`dealerId`, `saleDate`, `generatedAt`,
     `schemaVersion`, `payload Json`), replacing any earlier push for the
     same `dealerId` + `saleDate`, so the page always reads the latest.
     RLS on it like every other tenant table (`prisma/ops/create-app-role.sql`).
   - Reply `200` with `{"received": <number of cars stored>}`.
     BidBrain shows a note if that number differs from what it sent.
   - Any other failure: a `5xx` with a one line JSON `error`. BidBrain
     shows the first 200 characters to the dealer.
2. **The page**, the empty "BidBrain" slot in the Overview sidebar
   group: read the newest list for the current tenant and show it,
   shortlisted first sorted by `reserve`, held below. BidBrain's own
   cockpit (`bidbrain/render.py`, `_shortlist_card`) is the reference
   for what a dealer expects to see on a card: photo, make/model/year,
   reg plate, reserve, **max bid** big, retail, a link out to the
   listing, the flags.
3. **Env**: `BIDBRAIN_API_KEYS` (`<dealer-slug>=<token>,...`, one token
   per dealership, the tenant resolved from the token) documented in
   `.env.example`, name only.
4. **The way back** (below): `GET /api/bidbrain/actions` and
   `POST /api/bidbrain/status`, both `PUBLIC_EXCEPTIONS`, plus the state
   tables (`BidBrainStar`, `BidBrainHide`, `BidBrainView`,
   `BidBrainAction`, `BidBrainStatus`) and the session gated routes the
   page itself uses (`/api/bidbrain/stars`, `hides`, `hides/clear`,
   `views`, `views/[id]`, `settings`, `state`, `runs`, `runs/stop`).

## The widened document (2026-09-02, still schema 1)

Once Dealer OS became the only screen people use for live auctions, the
push grew to carry everything BidBrain's own cockpit draws, so Dealer OS
never re-derives a label, a family, a headroom figure or a flag colour.
Every addition is a new field; nothing was renamed or removed, so
`schema_version` stays 1 and an older Dealer OS ignores what it does not
know. The one trap kept on purpose: `suggested_bid_comps` is the COUNT of
comparable sales; the list itself is `drawer.compmatch.comps`.

Per car, on top of the record above: `key` (reg, or `listing_url` for a
car with no plate; the identity a star or hide uses), `platform_key`
(`motorway|carwow|auction4cars|dealerauction|dealerway|other`), `name`,
`search`, `make_canon`, `model_family`, `engine_label`, `engine_litres`,
`gearbox_label`, `reserve_label` ("Reserve", or "Trade value" on Carwow,
"AT Trade" on Dealer Auction, "Bid price" on DealerWay), `photo_urls`,
`keeper_start`, `selling_vrm`, `cazana_url`, `glass_checked`, `insights`
(`[{kind: red|amber|green|info, topic, text}]`, the card's traffic light
rows), `pricing.spread`, `headroom`, `cazana_headroom`, `cazana_price`,
`taste_match`, `taste_score`, `pick_rank`, `pick_score`, `is_new`,
`seen_before`, `starred` (on the Mac at push time), and `drawer`
(BidBrain's `render._drawer_data` verbatim: facts, the pricing receipt,
flags, `compmatch {suggested, n, like_for_like, ratio_based, capped,
comps[]}`, `history {previous, same_day, reserves, sold}`).

Document level: `partial` (a mid run push, one platform finished),
`summary`, `notices` (`[{text, kind}]`), `model_families`, `filter_scope`,
`lenient_filters`, `platforms` (`{key: {label, enabled, hidden}}`),
`settings` (`details_style`, `default_price_ceiling`, `top_picks`,
`theme`, `updated_at` per key), `integrations`, `run_checks`,
`bidbrain_version`, and `state` (below).

Size: about 5 KB per car, 0.6 MB for a normal day. Gzipped since
2026-09-21, and plain JSON compresses about ten to one: the largest real
day seen so far, 2,078 held back cars and 5.17 MB of JSON, goes over the
wire as roughly 555 KB.

Two ceilings, both checked before a push leaves the Mac. The document
itself must stay under 19 MB, a margin under the 20 MB Dealer OS stops
decompressing at, and what travels must stay under 4 MB, a margin under
Vercel's own 4.5 MB request cap. Over either, BidBrain trims each car's
photo gallery to 12 and tries again; over them still, it caps `held_back`
and `lost_bids` to 200 rows each and says how many it left off in
`capped`. Compressed, a real day is nowhere near any of this, so the
trims are a last resort rather than a normal Tuesday.

## The way back (2026-09-02)

Dealer OS is the source of record for anything a person does. Every
star, hide, unhide, saved view, display setting change and run request
made in Dealer OS is written to its own tables AND to an append only
action log. The Mac ASKS Dealer OS (nothing ever calls into the Mac):

- `POST /api/bidbrain/status`, the heartbeat, every 5 s (since v3.14.8;
  before that 15 s idle and 5 s while busy), bearer token as for the push. Body: `{at, bidbrain_version,
  cursor, light, progress, flags, busy_reason, run_status, site_health,
  platform_enabled, hidden_connections, integrations, runs, last_command,
  version}` (what the cockpit's own light and Run modal read). Reply
  `{ok, pending}`: how many actions are waiting past `cursor`. `version`
  (since v3.14.5) is `{current, latest, update_available, checked_at,
  error}`: the release this Mac is on, the newest GitHub Release, whether
  they differ, when that was last looked up (kept 30 minutes), and the
  lookup error if any (gh missing, no network).
- `GET /api/bidbrain/actions?since=<seq>&limit=500`, bearer token. Reply
  `{cursor, server_time, actions: [{seq, id, kind, at, by: {user_id,
  email}, payload}]}`, oldest first. Kinds: `star.set` (payload is the
  cockpit's own cardData dict: `reg, sale_date, on, make, model, name,
  max_bid, reserve, details {year, mileage, grade, fuel, body_type,
  cap_clean, platform}, listing_url`), `hide.set` (`reg, reason, make,
  model, name`), `hide.clear` (`reg`), `view.save` (`id, name, criteria,
  starred`), `view.star` (`id, starred`), `view.delete` (`id`),
  `settings.set` (`key` in `details_style`, `default_price_ceiling` only,
  `value`), `run.start` (`key, platforms?`), `run.stop`, and since v3.14.5
  `update.start` (no payload): move this Mac to the latest GitHub Release,
  the same one click update the Mac's own Settings page has. The Mac
  fetches tags, checks out the release and restarts its server; refused,
  with the reason in the next heartbeat's `last_command` (`key` "update"),
  while a run or login is in flight, when there is no newer release, or
  when the working tree has local changes. Dealer OS's own copy of the
  contract file and the Mac's may differ for a minute around an update;
  the Mac's copy is the truth. Since v3.14.6 `diagnostics.pull` (no
  payload): send the diagnostics document (below) now; answered in the
  next heartbeat's `last_command` (`key` "diagnostics", `started` true
  once it has gone, false with `reason` when the send failed).
- `POST /api/bidbrain/diagnostics` (since v3.14.6), bearer token: what
  the Mac sends so nobody needs its screen to see why something went
  wrong. Body `{at, bidbrain_version, logs: [{name, size, modified_at,
  lines}], run_status, site_health, progress, process: {pid, python,
  started_at, uptime_seconds}}`. `logs` is the tail of every file under
  the Mac's data/logs (the last 300 lines, each cut at 400 characters,
  newest file first, at most 8 files). `run_status` and `site_health` are
  the full records (the heartbeat only carries each summary's first
  line); `progress` is the live progress file. Sent when Dealer OS asks
  (`diagnostics.pull`), on the tick after a run finishes, and otherwise
  at most every 10 minutes and only when a log has grown. Reply `{ok}`.
  A 404 (an older Dealer OS) is not a fault; the Mac just waits. Dealer
  OS keeps the latest document only, one per dealership.
- The Mac applies each through the same functions its own cockpit uses
  (bids plus the watchlist queue, hidden_cars so hide learning still
  sees it, saved_views, settings, the run registry), stamps the local row
  with the action's own `at`, moves its cursor, rebuilds its own page,
  and echoes its whole state back in the next push under `state`:
  `{stars: [{reg, sale_date, on, updated_at}], hidden: [{reg, reason,
  name, make, model, active, hidden_at, updated_at}], views: [{id, name,
  criteria, starred, deleted, updated_at}], cursor}`. Dealer OS upserts
  where the Mac's `updated_at` is newer, writing no log row, so nothing
  loops. Last write wins per key (stars on reg plus sale date, hides on
  reg, views on id, settings on key); timestamps are UTC ISO.

Sharp edges both sides keep to: a hide on either side drops the car from
the next push's `cars`, so Dealer OS shows its Hidden list from its own
hide table, never from `cars`; a view's `criteria` is the cockpit's own
filter control state and round trips verbatim; a car with no plate can
be shown but never starred or hidden (both key on the registration).

Criteria additions on 2026-09-06 (still schema 1, all additive): `make`,
`model`, `servicehistory`, `grade` and `bodytype` may now carry a comma
separated list, meaning any of those values; a single value reads exactly
as before. Six new keys, `yearmin`, `yearmax`, `enginelitresmin`,
`enginelitresmax`, `ownersmin` and `ownersmax`, are inclusive ranges;
the old single value keys `year`, `enginelitres` and `owners` are still
honoured when set. The Mac's own screen (v3.14.12) matches both, and
carries a value its own boxes cannot show (a list, a range) through a
view untouched, so the list, the count and the view chip agree with
Dealer OS while the boxes read Any.

Status follows the check in (BidBrain v3.14.13, 2026-09-06, no schema
change): a `purchase.set` of `stock_status` to a finished answer on a car
with no `checked_in_at` is applied as a check in (arrival time stamped,
DealerKit half attempted), and to a non finished answer on a checked in
car as an undo (arrival time cleared), refused once `dk_checked_in_at` is
set. Dealer OS v3.12.0 sends `purchase.checkin` for the first case itself
and mirrors the refusal, so the two agree.

DealerKit wins on arrival (BidBrain v3.14.15, 2026-09-07, still schema 1,
additive): each purchase's `dk_check` may now carry `stage` (words: Due In,
In Stock, Sold, or DealerKit's own label), `life_cycle_status` (DealerKit's
number) and `delivery_on` (DealerKit's arrival date). `checked_in_at` may be
a bare day (`YYYY-MM-DD`) when the arrival date came from DealerKit rather
than a button press.

Ordered and Sold (BidBrain v3.14.17, 2026-09-07, still schema 1,
additive): the `stock_status` answers may now include "Ordered"
(DealerKit's Awaiting Delivery, a customer is waiting for the car) and
"Sold", both `finished`. The Mac moves a car's `stock_status` to match
DealerKit's stage on every DealerKit read (the daily run and a quiet check
every 15 minutes), with a timeline line "matched from DealerKit"; Dealer OS
shows whatever word the push carries. `purchase.checkin` may carry
`status`, the finished answer the person picked; absent, In stock. A chip,
transport fee, check in or removal is sent to DealerKit the moment the Mac
applies it, so `dk_*_pushed_at` stamps normally follow within a minute.

## Purchases, Under Offer and Didn't win (2026-09-02, still schema 1)

Three more top level lists ride along in every push, so Dealer OS can draw
the Mac's other three pages. Each row is exactly what the Mac's own page is
given by its API, minus paths on the Mac's own disk:

- `purchases`: every row of the Mac's Purchases working list, the same
  fields `GET /api/purchases` returns there (reg, name, platform, status,
  stock_status, bought_date, collection_date, mileage, year, transmission,
  engine, grade, the canonical money figures `bid_original`, `chip_total`,
  `car_price`, `effective_fee`, `effective_vat`, `fees_total`, `all_in`,
  `winning_bid`, `motorway_fee`, `transport_fee`, `protect_fee`,
  `vat_total`, `price`, `total_price`, `payment_estimated`,
  `retail_estimate` (with `_raw`, `_capped`, `_override`), `cazana_115`,
  `bias_bid`, `chips` (a list of frozen chip events), `notes`,
  `video_status`, `person_collecting`, `paid_for`, `on_finance`,
  `logbook_state`, `collection_arranged`, `location`, `supplier`,
  `manual_entry`, `cancelled_at`/`cancelled_reason`/`cancelled_source`,
  `checked_in_at`, `photo_url`, `photo_urls`, `service_history_photos`,
  `v5_photos` (URLs on Motorway's own CDN), `listing_url`, `vehicle_ref`,
  `dk_check` and the `dk_*_pushed_at` DealerKit sync stamps (plus, since
  v3.14, `dk_chip_pushed_at`, `dk_transport_pushed_at`, `dk_checked_in_at`,
  `dk_removed_at`, `dk_mileage_pushed_at`, `dk_fee_pushed_at`),
  `dealerkit_pushed_at`, `emailed_at`, `first_seen`, `id`). `document_paths`
  and `v5_local_paths` are never sent (local files). Cancelled rows are
  included with their reason; the "In stock" `stock_status` is the archive.
- `under_offer`: the live snapshot of the Mac's Under Offer page
  (`reg`, `name`, `platform`, `max_bid`, `noted_at`, `listing_url`,
  `vehicle_ref`), empty when nothing is under offer.
- `lost_bids`: the Didn't win page's rows (`reg`, `name`, `platform`,
  `date_bid`, `our_bid`, `sold_for`, `mileage`, `year`, `transmission`,
  `engine`, `grade`, `cazana_retail`, `cazana_checked`, `bias_bid`,
  `retail_estimate`, `cazana_115`, `listing_url`, `vehicle_ref`, `id`).
- `purchase_settings`: the per dealership `purchase_dropdowns` (each
  dropdown column's answers, with colour, default, finished and retired
  flags, in order), `purchase_columns` (which columns show, their order
  and widths), `purchase_flow` (video_stage on or off, fee re bands per
  platform) and `purchase_views` (saved views) the Purchases page is built
  from.

Two more lists came the same day:

- `purchase_events`: every purchase's timeline in one flat list, newest
  first, each row `{id, purchase_id, ts, kind, text, field, was, now,
  body, who}` (kind is `field`, `chip`, `comment`, `status`, `cancel`,
  `checkin` and so on; `who` is the dealership name, a person's email, or
  "Dealer OS" for a change made there). Group by `purchase_id` to draw a
  record's Timeline tab.
- `lost_bids_views`: the Didn't win page's own saved views, the same shape
  as `purchase_settings.purchase_views`.

Purchase galleries are trimmed with the car galleries when a push runs
over the size guard.

### Documents: lasting links (live on both sides since 2026-09-03)

A purchase's service history and V5 scans live as FILES on the Mac's own
disk (the platform links either expire within the hour or never existed).
The push never sends a local path. To give Dealer OS a lasting link the
Mac uploads each file once, before a push, to:

    PUT /api/bidbrain/documents?reg=<REG>&name=<original filename>
    Authorization: Bearer <the dealer's token>
    Content-Type: image/jpeg | image/png | application/pdf
    body: the raw bytes (Dealer OS refuses over about 4.5 MB with 413, so
    the Mac downsizes any image over 4 MB to a 2000 px JPEG first and
    skips a PDF that big)
    reply 200 {"url": "https://.../<lasting link>"}

Dealer OS stores the bytes in its media bucket (the same store
`src/lib/media/storage.ts` uses, under the dealer) and replies with a link
that keeps working. The Mac remembers each link against the file and
never sends the same file twice. On a Mac that downloads a purchase's
photos too (Steven's does, since Motorway's photo links die), `photo_urls`
and `photo_url` go through the same upload, so those carry lasting links
as well. Three failures in a row stop the batch for that push (a broken
store, not one bad file); at most 150 files go per push, the rest follow
on later pushes. Until the route exists (404) the Mac
sends nothing and the push carries no link for that file, so a Carwow
car reads "No service history" on Dealer OS until then. Machine route:
bearer only, `PUBLIC_EXCEPTIONS`, `prismaForDealer`.

### The way back for purchases (2026-09-02, step one)

Three action kinds the Mac pulls and applies, exactly like `star.set`:

- `purchase.set {id, field, value}`: the everyday fields. `field` must be
  one of `notes`, `stock_status`, `on_finance`, `video_status`,
  `person_collecting`, `paid_for`, `collection_arranged`, `logbook_state`,
  `location` (and on a hand added car, `reg`, `name`, `mileage`, its
  money fields and, since BidBrain v3.14.9, `photo_urls`). A dropdown
  value must be one of that column's configured options in
  `purchase_settings.purchase_dropdowns`. A platform read figure is
  refused and skipped with a note.
  - `photo_urls` (2026-09-06, still schema 1): a hand added car has no
    listing to read photos from, and DealerKit will not take a car
    without them. Dealer OS stores each picture in its own media store
    (the same store the document scans use, a lasting `/media/...` link)
    and sends the WHOLE gallery as `value`, a list of https links, at
    most 40, in the order they should show; an empty list clears it. The
    Mac stores the list as the car's `photo_urls`, sets `photo_url` to the
    first, writes one timeline row saying how many ("Photos changed, 0 to
    3"), and the next push carries them back. A platform car is refused,
    its gallery is the listing's. The DealerKit send (`purchase.dk_send`)
    downloads from these links the same way it does a Carwow gallery.
- `purchase.override {id, field, value}`: `field` is `retail_estimate` or
  `collection_date`; `value` null clears the override.
- `purchase.comment {id, text}`.

`id` is the purchase's own `id` from the push. `by.email` (or name) is
written into the timeline as who made the change. Applied in log order;
the later action wins. Still to come (step two and three): chips, cancel
a sale, check in, add a car by hand, DealerKit uploads, column layout and
saved views coming back.

### The way back for purchases, step two (live on both sides, proven end to end 2026-09-03)

Six more action kinds, each applied on the Mac through the SAME door the
Mac's own Purchases page uses today (the request handlers in serve.py:
record chip, cancel, check in, add by hand, the DealerKit row buttons, the
views save), so every check below is the one that page already makes,
nothing looser. Applied in log order, `by.email` written as `who`. A
refusal is logged with its reason (visible in the Mac's pull log) and the
action is skipped, never retried. `id` is always the purchase's own `id`
from the push. Amounts are pounds, numbers not strings.

**purchase.chip {id, amount, reason}**, an agreed price reduction.
Checked: `amount` is a positive number; the sale is not cancelled.
Refused: a missing id, a zero or negative amount, a cancelled sale.
`new_fee` and `new_vat` are NOT accepted from Dealer OS: the re banded
platform fee is computed on the Mac at chip time from the dealership's own
fee band tables and frozen onto the chip (Motorway re bands, Carwow never
does, per `purchase_settings.purchase_flow.fee_rebands`), so send only the
amount and the reason and read the result back from the push. Afterwards:
one `chips` entry on the purchase (`amount`, `reason`, `bid_before`,
`new_fee`, `new_vat`, `who`, `ts`), `chip_total`, `car_price`,
`effective_fee`, `effective_vat` and `all_in` move, and one
`purchase_events` row of kind `chip` with the before and after figures.

**purchase.cancel {id, reason_type, reason_text}**, the sale fell through.
Checked: `reason_type` is one of the configured
`purchase_settings.purchase_dropdowns.cancelled_reason` values;
`reason_text` is non empty (kept for the proof pack); the sale is not
already cancelled. Refused: an unknown reason type, an empty text, a
second cancellation. Afterwards: `cancelled_at`, `cancelled_reason`
("type · text") and `cancelled_source` "dealer_os" on the row, the car
leaves the working list and appears in the Cancelled view, the reg is
suppressed from future shortlists (walked_away), and one `purchase_events`
row of kind `cancel`.

**purchase.checkin {id}**, the car has arrived and is now stock. Checked:
the sale is not cancelled. Refused: a cancelled sale, an unknown id.
Afterwards: `checked_in_at` set, `stock_status` "In stock" (the archive),
and one `purchase_events` row of kind `checkin`. Since v3.14 this also
checks the car in on DealerKit when it is on there, through DealerKit's own
Check in button (see "Check in" under DealerKit from Dealer OS below).

**purchase.add {reg, name, price, bought_date, supplier, mileage, fee,
transport, retail_estimate, notes}**, a car bought outside the platforms.
Checked: `reg` and `name` present; `price` and `mileage` positive numbers;
`fee` and `transport` zero or positive; `retail_estimate` positive if
given; `supplier` one of `purchase_settings.purchase_dropdowns.supplier`
(send `supplier_new: "<name>"` instead to add a new supplier, which goes
through the same option add the Mac's own form uses); `reg` not already on
the working list (a reg on a sold or cancelled car is allowed, it can be
bought again). Refused: a duplicate working list reg, a missing reg or
name, a bad number, an unknown supplier. VAT is never accepted, it is
computed from each fee's own VAT flag. Afterwards: a new purchase row
(`platform` "Manual", `manual_entry` 1), sent back in the next push with
its new `id`, and one `purchase_events` row of kind `added`.

**purchase.dk {id, action}**, the DealerKit buttons. `action` is one of
`price`, `photos`, `documents`, `retail`, `due_in` (each starts the
"push one item to DealerKit" job for that purchase with that item ticked)
or `check` (re reads the DealerKit record). Checked: the action name; the
purchase has a mileage (a car with no mileage can never create a
DealerKit record, no exceptions); nothing else is already running on the
Mac (one browser at a time). Refused: an unknown action, no mileage, the
Mac busy (reported in the next heartbeat's `last_command` with the reason,
exactly like `run.start`). Afterwards: the job runs on the Mac; when it
finishes the purchase's `dk_check` and the matching `dk_*_pushed_at`
stamp are updated and come back in the next push, and one
`purchase_events` row of kind `dealerkit` says what was sent or found (and one more, by BidBrain, with the job's own outcome, including a failure). An expired DealerKit login is renewed with the stored password once before the job gives up.

**views.save {page, name, criteria, starred}** and **views.delete {page,
name}**, a saved view made on Dealer OS's Purchases or Lost page. `page`
is `purchases` or `lost`. `criteria` is the SAME shape the Mac's own pages
save and the push already carries in `purchase_settings.purchase_views` and
`lost_bids_views`: for purchases the page's own filter ids (`filterstatus`,
`filtercancelreason`, `datefrom`, `dateto`, `collfrom`, `collto`,
`filtervideo`, `filterperson`, `filterfinance`, `filterlogbook`,
`filterpaid`, `filterdocs`, `filterpricemin`, `filterpricemax`,
`filterretailmin`, `filterretailmax`, `filtermarginmin`, `filtermarginmax`,
`filtermilesmin`, `filtermilesmax`, `search`) plus `sortKey`, `sortDir` and
`purchase_columns` (the column layout, an object or its JSON string, stored
as `columns`); for lost `bidfrom`, `bidto`, `lostbymin`, `lostbymax`,
`soldformin`, `soldformax`, `search`, `filterrecentonly`, `sortKey`,
`sortDir`. Empty values are dropped before sending. Checked: `name` non
empty and under 60 characters; the whole list goes through the same
validator the Mac's own page saves through, so an unknown key is dropped
rather than refused, and a view saved on Dealer OS applies on the Mac's own
screen too. Refused: an unknown page, an empty name. Afterwards: the
view appears in `purchase_settings.purchase_views` or `lost_bids_views`
in the next push; a delete removes it. No `purchase_events` row, a view
is not a change to a car.

Not in step two: the column layout (`purchase_columns`) coming back and the
dropdown option editor. Deleting a hand added car is `purchase.delete`
below (2026-09-05).

### DealerKit, sent whole then kept in step (2026-09-05, BidBrain v3.14, still schema 1)

The new way of working for Dealer OS's redesigned DealerKit tab. A car is
not sent to DealerKit until its collection date is confirmed. Pressing Send
adds the car with everything in one go; after that the retail price and
the due in date belong to DealerKit, and only a chip, the transport invoice
and a check in flow from Dealer OS afterwards. Cancelling removes the car
from DealerKit. All of it goes through the same action door as
`purchase.chip` and `purchase.dk`. Every new kind and field is additive;
an older Dealer OS keeps working.

The send list for the whole car (`SEND_ITEMS`) is: price, fee, mileage,
photos, documents, retail. The follow up list (`FOLLOWUP_ACTIONS`) is: chip,
transport, checkin, remove; the automatic run also treats retail as a
follow up (see "Retail follows an edit" below). `due_in` is no longer on
either list.

**purchase.dk_send {id}**, one job creating the DealerKit record if there
is none and sending: registration and mileage together (typed side by side
on DealerKit's own "Add Vehicle To Stock" form, see below), purchase
price, buyer's fee, photos, service history and V5 if any, and the retail
price. The due in date is NOT sent (2026-09-05, Steven's decision, see
Check in below). Refused, each with its own
plain sentence and all of them together in one message when several
apply, so Dealer OS can show the lot:

- `S17OUG is a cancelled sale, nothing to send.`
- `S17OUG has no mileage on file. Enter the mileage first, DealerKit is never allowed to guess it.`
- `S17OUG has no purchase price on file yet.`
- `S17OUG has no buyer's fee on file yet. Enter the fee, zero if there was none.`
- `S17OUG has no photos on file yet.`
- `S17OUG has no retail estimate yet. Set the retail price first.`
- `S17OUG has no collection date yet. Confirm the collection date first.`
  (checks `collection_date`, the due in date, never `collection_arranged`)
- `A DealerKit push for one car is in progress.` (or another busy
  sentence: the Mac runs one browser job at a time)

On finish the push carries the refreshed `dk_check`, `dealerkit_pushed_at`
and every `dk_*_pushed_at` stamp (`dk_price_pushed_at`, `dk_fee_pushed_at`,
`dk_mileage_pushed_at`, `dk_photos_pushed_at`, `dk_documents_pushed_at`,
`dk_retail_pushed_at`; `dk_due_in_date_pushed_at` stays in the row but is
never set by a send any more), plus one
`purchase_events` row of kind `dealerkit` by BidBrain saying what each item
did (`set to £74995`, `already right on DealerKit, 37,000 miles`, `nothing to
send, ...`). A send on a car DealerKit already has does not create a second
record; it corrects the price and fee, writes the mileage, tops up
documents, leaves the photos alone when DealerKit already holds as many as
BidBrain has, and sets the retail price only if DealerKit has none.

A send that fails for any reason (since v3.14.10, not only an expired
login) writes one `purchase_events` row of kind `dealerkit` by BidBrain,
`DealerKit send failed: ...`, carrying DealerKit's own words where it
would not add the car (`DealerKit did not add TE57DOS after its
registration was submitted. The Add Vehicle window says: ...`). Nothing is
stamped. Dealer OS reads that row to say the send failed and why, and
offers Send again.

**Mileage.** The Mac never used to send the mileage at all; DealerKit
fills in a guessed figure of its own when a record is created with no
mileage. Since v3.14.2 a send types BidBrain's own mileage into the
Mileage box beside the registration on DealerKit's own "Add Vehicle To
Stock" form, so the record is created with the real figure from the
start (a car DealerKit already has gets a mileage log entry instead when
its figure differs). The rule stands the other way too: a car with no
mileage can never create a record, which is why it is one of the send
refusals.

**Chips: `purchase.dk {id, action: "chip"}`.** After a `purchase.chip` on
a car on DealerKit, sends the new purchase price and the new buyer's fee
(the chip's own re banded fee where the platform re bands). Recording a
chip clears `dk_chip_pushed_at`; sending it stamps it. Also sent by the
automatic DealerKit run. Refused: `... is not on DealerKit, nothing to
send.`, `... has no chip recorded, nothing to send.`, `... is a cancelled
sale, nothing to send.`

**Transport: `purchase.dk {id, action: "transport"}`.** After the
`transport_fee` is set with `purchase.set` on a car on DealerKit, sends it
as the Delivery Charge line on the supplier's expense. Setting the fee
clears `dk_transport_pushed_at`; sending it stamps it. Also sent by the
automatic run. Refused: `... is not on DealerKit, nothing to send.`, `...
has no transport fee on file, nothing to send.`

**Check in: `purchase.checkin {id}` now also checks the car in on
DealerKit when it is on there** (best effort: a busy Mac leaves it to the
automatic run), and `purchase.dk {id, action: "checkin"}` does the same on
demand. What "checked in" means on the DealerKit side: the car moves from
Due In to In Stock with its arrival date on the record; it is stock, no
longer expected. The Mac does it through DealerKit's own CHECK-IN button
(the "Check-in / Move" form, Delivered On set to the arrival day). This is
why the due in date is no longer sent: a Due In / In-stock On date on the
record makes DealerKit hide that button and check the car in by itself the
moment the date arrives, and Steven wants the check in to be a person's
decision, not DealerKit's timer. A record that already carries a date
(sent before v3.14.2, or set by hand in DealerKit) is still checked in by
writing the arrival day onto that date, which moves it the same way.
Stamps `dk_checked_in_at`. Refused: `... is not on DealerKit, nothing to
send.`, `... has not been checked in on BidBrain yet.`, a cancelled sale.

**`purchase.dk {id, action: "due_in"}` is refused** since v3.14.2 with
`The due in date is no longer sent to DealerKit. Check the car in when it
arrives and DealerKit will follow.` Dealer OS should drop that button.

**Remove: after `purchase.cancel` on a car on DealerKit the record is
removed at the next automatic run; `purchase.dk {id, action: "remove"}`
is the Remove now button.** Only a car DealerKit still shows as Due In is
deleted. A car DealerKit already shows as In Stock (or beyond) has been
handled on that side and is left alone; the timeline row says `DealerKit
shows this car as In Stock, not Due In, so it was left in place. Remove it
in DealerKit by hand if that is right.` Stamps `dk_removed_at` (which also
clears every other DealerKit stamp, the car is off DealerKit as far as the
Mac knows; a later send starts clean). Refused: `... is not cancelled.
Cancel the sale first, then remove it from DealerKit.`, `... is not on
DealerKit, nothing to send.`, and `... was also bought as a live sale that
is still on the list, so its DealerKit record was left alone.` (DealerKit
is found by registration, so a cancelled sale sharing its registration
with a live one is never removed).

A follow up that finds no DealerKit record at all records the car as not
on DealerKit (`dk_removed_at` set) rather than trying again every run.

**purchase.delete {id}**, deletes a car from the Mac's Purchases list.
Only a hand added car (`manual_entry` 1); a platform purchase is refused
by the Mac itself (`only a car added by hand can be deleted, a real
platform purchase can only ever be cancelled.`). Refused while the car is
on DealerKit: `S17OUG is on DealerKit, so it cannot be deleted from the
list. Cancel the sale instead.` Afterwards: one `purchase_events` row of
kind `deleted` (the timeline is KEPT, only the car and its chips go), and
the row leaves the next push entirely. Since v3.14.11 a hand added car that
was cancelled first also loses its own cancellation record, so its plate is
not kept off future shortlists and no grey shell row is left in the
Cancelled view; the `deleted` row says so.

**New purchase fields in the push:** `dk_mileage_pushed_at`,
`dk_fee_pushed_at`, `dk_chip_pushed_at`, `dk_transport_pushed_at`,
`dk_checked_in_at`, `dk_removed_at`, all timestamps or null. A car is on
DealerKit as far as the Mac knows when `dealerkit_pushed_at` is set (or a
`dk_check` found a record) and `dk_removed_at` is not.

**`dk_check`** keeps carrying `retail_price` (DealerKit's own figure) and
`due_in_date` (true when DealerKit has a Due In / In-stock On date, which a
send no longer sets, so it now normally reads false until the car is
checked in) on every check, alongside `vehicle_id`, `images`, `documents`,
`purchase_cost_value`, `due_in`, `auction_fee`, `delivery`, `indemnity`,
`service` and `v5`.

**The automatic DealerKit run** is `com.bidbrain.dealerkit-purchases` in
`mac_settings.run_schedule`, 16:15 daily (after the 15:30 DealerKit login
and the 15:50 purchases sync). Since v3.14.3 it NEVER sends a car whole:
a car goes to DealerKit only when a person presses Send in Dealer OS
(`purchase.dk_send`), Steven's decision. The run only picks up chips,
transport fees, check ins, removals and (since v3.14.4) retail prices not
yet sent on cars DealerKit already has, the same rules as the buttons.

**Retail follows an edit (v3.14.4).** Dealer OS sends `purchase.dk {id,
action: "retail"}` the moment a retail estimate is edited on a car on
DealerKit. That job ALWAYS writes BidBrain's current `retail_estimate` to
DealerKit, overwriting whatever DealerKit already had (the timeline row
reads `changed from £9799 to £8995`); only a send whole leaves an existing
DealerKit retail price alone. If the Mac was busy when the edit landed,
the automatic run sends it: on every car on DealerKit whose last
`dk_check.retail_price` is missing or differs from `retail_estimate`, the
run writes the retail price and updates the stored `dk_check` so it is
not sent again the next day. Working list cars only: a finished car (In
stock or any status the dealership flags as finished, or checked in)
belongs to DealerKit from there and its retail price is never rewritten by
the run; the Dealer OS retail button still works on such a car, that is a
person's click. A car never checked on DealerKit (`dk_check` null) is left
to the next Check or the next Send.

**Which field "has no collection date yet" checks:** `collection_date`
only, the due in date (the effective one, a `collection_date_override`
counts). `collection_arranged`, the booked collection time, is NOT read by
the preflight, so a car with a booking but no due in date is still refused
until the due in date is set.

### Step three: the Mac's settings from Dealer OS (2026-09-03, live in BidBrain v3.13)

**What the push carries.** A new top level `mac_settings` block, everything
the Mac Studio's own Settings page shows, so Dealer OS can draw a settings
page from the push alone:

- `buying_rules`: the current value of every rule Dealer OS may send back
  (the list below), in the push's own shapes.
- `integrations`: `{key: {enabled}}` for `dealerkit_stock`,
  `dealerkit_purchases`, `glass`, `lecapital_funding`, `dealer_os_push`.
- `hidden_connections`: the site keys hidden on the Integrations tab.
- `fee_bands`: the per platform buyer fee tables (read only).
- `banned_engine_names`: the engine bans (read only, code on the Mac).
- `run_schedule`: `[{job, time "HH:MM", day, command}]`, the Mac's own timed
  jobs read from its scheduler files (read only).
- `editable_sections`: the section names settings.set accepts today.
- `updated_at`: the Mac's own stamp per key, for newest wins.

`purchase_settings` (the dropdown answers, column layout, journey flow and
saved views) is unchanged and still carried alongside.

**settings.set {section, value}**, applied on the Mac through the same
validator its own page saves through, in log order, newest wins per key
(an action older than the Mac's own last save of that key is skipped with
a note), `who` = `by.email` in the Mac's log. `value` is the WHOLE block
in exactly the shape the push carries for it, replaced as one. Sections:

- `purchase_dropdowns`: `{column: [{value, colour, default?, finished?,
  gate?, retired?}]}`. Checked: only the known columns (`stock_status`,
  `video_status`, `person_collecting`, `on_finance`, `location`,
  `logbook_state`, `supplier`, `cancelled_reason`); every answer a non
  empty string, no duplicates (any case); colour one of grey, blue, navy,
  green, teal, teal2, sky, yellow, purple, pink, red, orange, amber; at
  most one `default` per column; `stock_status` must keep at least one
  `finished` answer. Refused outright: removing an answer that real cars
  still carry ("'Due in' is still on 12 car(s). Retire it instead of
  removing it, or change those cars first."), so an answer in use can only
  be marked `retired`, never deleted. A list may be emptied only if no car
  carries any of its answers.
- `purchase_columns`: `{column: {table, record, w?, pos?}}`. Checked:
  `table`/`record` become plain true or false, `w` must be 40 to 900
  pixels, `pos` 0 to 99, anything else on a column is dropped. Only the
  known columns matter; the page ignores a column it does not have.
- `purchase_flow`: `{video_stage, fee_rebands: {platform: bool}}`. Checked:
  both become plain true or false per key.
- `buying_rules`: any subset of the rules below; each key is validated by
  the Settings page's own rules, then saved. Unknown keys are refused
  by name. `platforms` and `platform_enabled` are merged per platform, so
  one platform's gate can be sent alone.
- `integrations`: the whole `{key: {enabled}}` map, every known key
  present, unknown keys refused.

**Buying rules Dealer OS may change (section `buying_rules`), value shapes:**

| key | shape | what the Mac checks |
|---|---|---|
| `retail_uplift` | number, percent (15 = 15%) | positive, at most 100 |
| `flat_spread` | number, pounds | positive |
| `retail_price_cap` | number or null | positive when set |
| `retail_estimate_margin` | number, pounds | zero or more |
| `retail_estimate_ending` | 95 or 99 | one of the two |
| `small_car_cap_limit` | number, pounds | positive |
| `default_price_ceiling` | number or null | positive when set |
| `details_style` | "drawer" or "popup" | one of the two |
| `dealer_postcode` | string | reference only, upper cased |
| `banned_makes` | list of strings | non empty strings, lower cased |
| `banned_models` | list of [make, model] pairs | pairs of non empty strings |
| `van_models`, `small_car_tokens` | list of strings | non empty strings |
| `value_caps` | list of {make, model or null, cap, except?} | make and a positive cap |
| `platforms` | {platform: {max_mileage, max_reserve, min_age, max_age, max_owners, allowed_grades, max_distance_miles or allowed_locations}} | per platform, each figure positive, min_age at most max_age, grades a non empty subset of 1 to 5 |
| `platform_enabled` | {platform: bool} | known platforms only |

**What stays on the Mac Studio, and why:**

- The Mac's own look (`theme_*` colours, logos, favicon): Dealer OS has
  its own theme; sending one would only ever style a screen the Dealer OS
  user is not looking at. Refused if sent.
- Fee band tables (`fee_bands`): the platforms' own published fee scales,
  changed only when a platform publishes new ones; shown read only.
- Engine bans and litre bans: code on the Mac, with tests behind each rule;
  shown read only as `banned_engine_names`.
- Run times (`run_schedule`): files in the Mac's own scheduler (launchd),
  not rows in the database; changing them needs a file edit and a reload
  on the Mac itself. Shown read only.
- Logins and relogins (Motorway, Carwow, DealerKit, the valuation sites):
  need the stored password on the Mac's own keychain or a person at the
  Mac; nothing to send.
- Hiding or showing a connection (`hidden_connections`): flips other
  switches with it (a hidden platform is also switched off), so it stays
  on the Mac's Integrations tab for now; shown read only.
- `purchase_views` and `lost_bids_views`: already `views.save` /
  `views.delete`, not a settings section.

## The shortlist deep read (2026-09-17, Dealer OS v4.1.0, BidBrain v3.14.125, still schema 1)

Dealer OS's shortlist screen shows one starred car at a time. "Read this
car" or "Read them all now" there logs

    kind "shortlist.read"
    payload { sale_date, asked_at, cars: [{ key, reg, platform, listing_url, name }, ...] }

naming only the starred cars, soonest closing first, never more than 60.
The sync (dealer_os_sync.apply_action) queues them on `deep_reads`
(db.queue_deep_reads, keyed the way Dealer OS keys a card: platform plus
key, the reg or the listing url for a car with no plate) and the server's
hook starts the quiet `deep_read` run (daily_run.deep_read_pass) now, or
the sync loop's catch up starts it on the next quiet moment. The pass
opens one headless session per platform in the saved login, reads each
car's own page once (readers/*.deep_read, parsed by bidbrain/deep_read.py)
and keeps the answer on the row; the push the server sends the moment
the pass ends carries it under each car's `deep` block, with `asked_at`
echoed from the ask it answers so Dealer OS knows the read is for this
ask, not an earlier one. A second ask re reads the car and pushes the old
answer, with its older asked_at, until the new one lands.

Two additive keys since v3.14.130, both null when the page did not show
them: `mot_tested` (the last MOT test's date; Motorway gives that, never
an expiry, and none is ever worked out) and `interior` (the trim, "Cloth").

Dealer Auction has no car page read, so its cars are answered with
`error` straight away; a dead login, a page that would not load or a
read that failed three times is an `error` in plain words too. Every
field the page did not show is null. Never a bid, never an action.

## Not in scope yet

- Purchases, lost bids, under offer. This is the daily shortlist only.
- The buying rules themselves stay on the Mac's own Settings page.

## How to test it end to end

On the Mac, with both files carrying the same secret:

```bash
cd ~/BidBrain && python3 daily_run.py --dealer-os-push
```

sends the last saved run. `DEALER_OS_BASE_URL` can point at
`http://localhost:3000` while the route is being built, then at
`https://www.cardealeros.co.uk` once deployed.
