Release notes
Two timelines: the documentation's own editions, then every platform release — the same history the app shows in-app.
The manual's own editions
The documentation ships on its own clock — this is the version pinned in the sidebar footer, and what each edition changed.
Docs v2.0
2026-09-28· Platform v3.0.0CurrentThree manuals
The Platform Manual became three manuals — Platform, Planning and Remarketing — each opening with Cedar's overview and what Cedar can do there, with how-to guides and practices beside the reference chapters.
- Platform, Planning and Remarketing, in the order a vehicle lives them.
- Every section opens with Cedar: what it is for, where to start, and the prompts Cedar answers there.
- Twenty-six new guides: nineteen how-tos and seven practices.
Docs v1.5
2026-08-26· Platform v2.4.0Delegation gets its chapter
The platform made "what may Cypress do for you" an explicit, org-controlled contract — three switches, one non-delegable line, a badge on every on-behalf action. The manual now tells that story once, properly, and then repeats its local consequences at the top of every chapter it touches, in the same badge vocabulary the app itself uses.
- A new Getting Started chapter — Delegation: Cypress on your behalf — the three switches and their defaults (lifecycle opt-out; sell and buy opt-in), the below-floor line no switch can move, and the three-part audit trail.
- Delegation banners on the ten chapters the switches govern — pricing, onboarding, launching, both offer chapters, both buying chapters — each stating what Cypress concretely does there when delegated, in the app’s own Lifecycle · Sell · Buy chips.
- The two offer chapters rewritten around the split that matters: countering is negotiation and comes with the lifecycle; accepting is disposal and requires the sell switch — and the dual-approval story the code never implemented is gone.
Docs v1.4
2026-08-25· Platform v2.3.0CRM and Marketing get their chapters
The manual had nothing at all to say about the CRM, about Marketing as a standalone module, or about the board that reads every queue at once — three of the places a commercial team now spends its day, and the three that replace running prospects out of a spreadsheet and a personal inbox. This edition writes them, and rewrites the two chapters that described an app which has since changed shape.
- A CRM section in three chapters: contacts and businesses, deals and pipeline, tasks and follow-up — including how deal temperature is computed and why it can be argued with.
- Mission Control gets a chapter, and with it the rule that makes the board readable: a summary line is still an account, so an empty board means there is nothing rather than that nothing was asked.
- Marketing separates from promotions and is documented as its own module: the audience as a live query against the CRM rather than a pasted list, one goal that the pipeline inherits, personalisation that never leaks a raw token, and what stops a campaign going out empty.
- Navigation rewritten around the lifecycle groups, and the acting-for lens explained where it matters most: it decides what you CREATE, not only what you see.
Docs v1.3
2026-08-20· Platform v2.1.0Cedar moves in
The documentation stops being a place you read and becomes a place you can ask. The platform’s own assistant now lives in the docs — the same controls, grounded in these chapters — and every chapter opens with a plain-English TL;DR.
- The real Cedar in the docs: the app’s own header controls, side panel, and Copilot mode, grounded in the manual through a read-docs tool — for signed-in readers and signed-out visitors alike.
- A TL;DR on every chapter — a pre-generated plain-English summary, with an “Explain this to me” button and three chapter-specific questions that hand the topic straight to Cedar.
- Answers cite chapters as links you can click, and Cedar can navigate you there — by text or by voice.
Docs v1.2
2026-08-20· Platform v2.0.4The agentic story, told properly
Analytics split along the line readers actually stand on — the seller measuring one vehicle versus the platform measuring the business — and the AI layer stopped being a footnote.
- Seller analytics (one vehicle, one promotion) separated from platform analytics, each with its own objective and decision playbook.
- Bright agent panels — “How Cedar can help here”, “What Sakura recommends here” — across fourteen chapters.
- A first documentation assistant you could chat with, grounded in the corpus.
Docs v1.1
2026-08-20· Platform v2.0.4Agentic-first, and four chapters deeper
The round that filled the gaps the first edition left: the assistant’s own chapter, the notification system, who may buy, and how an organization gets onboarded.
- New chapters: Copilot mode, notifications, buyers versus customers, and organization onboarding.
- Documents rewritten around bring-your-own paper: branded templates, which fields fill themselves, the gate pass, digital versus wet-ink signing.
- ARVIS and Orbis deepened with real captures, import auto-mapping explained honestly, and five automation recipes.
Docs v1.0
2026-08-19· Platform v2.0.4The platform gets its manual
/docs grew from developer documentation into the full story of the platform: twenty-six chapters following the reader’s journey, with one continuous deal threading the entire book.
- Twenty-six manual chapters, each one: what it is, how it works, a worked example, the business rules, and a short technical note.
- Two sections under one switcher — the manual beside the developer docs it used to be.
- Public release notes, version badges, and hand-drawn diagrams throughout.
Platform releases
What shipped in the product, newest first. The manual documents the platform as of the version in the sidebar footer.
v4.1.0
2026-11-02From a spreadsheet to a sold car, all from the phone
Grove 1.1.0 finishes what a seller and a buyer start: a car from the week's de-fleet spreadsheet to live, an offer, a counter, and every step of the deal to pickup, a card per step on each side, with no Cypress Manager needed. Cypress adds what that takes (API 1.10.0 and 1.11.0): paying the commission by card from Grove, each party seeing only its own signing links, the year ahead on every value, what holding a car costs, and pricing rules that list only ready cars.
Grove 1.1.0
- The post-sale process from Grove, a card per step. The deal's card shows every step and whose turn it is; each step has its card on each side (commission by card, the bill of sale and its signatures, funds, gate pass, title, pickup); Deal Setup is completed in place. Huddle groups conversations in Cypress's words: Seller Hub, Buyer Hub, Post-sale.
- Journeys, start to finish: what needs you (Today's three, See all, Work through them, Not now), what a car is worth and when to sell it (Sell or hold), getting a car live (Get it live), and finding cars to buy (Target watch); playbooks by journey, and rules Cedar proposes from choices you keep making.
- A first day: a first sign-in opens on Cedar's hello, What I can do is a carousel of walkthroughs, and notifications, the brief's time and installing are asked when they help.
- See it work: three stories recorded live, Cedar explaining each step, on Grove's front page and in Grove's docs.
API 1.11.0: paying the commission from Grove
- start_commission_payment (`POST /api/v1/closings/{id}/commission-invoice/checkout`, `closing:write`): for the seller, in an app Cypress made (Grove), the closing's commission invoice opened as Stripe's embedded payment, the one Cypress's Pay Now opens. It answers the form's `clientSecret` and Stripe's `publishableKey`; the person types the card into Stripe's own form, and card details never reach Cypress or the app. Once paid, the fee step completes as from Pay Now. `saveCard` keeps the card for the organization's next commissions (charged at Deal Setup). Paying stays a person's: the seller's people, or Cypress staff acting for the seller (as Pay Now lets them); agents, API keys, partner apps and the buyer are refused; a commission not invoiced yet, already paid or on a cycle bill is a conflict.
- Fix: each party sees only its own signing links (owner, 2026-11-01). The seller used to get every signer's link, the buyer's included (Post-sale's roster, `get_closing_signatures`, the older flow's buyer link), so it could have signed in the buyer's place. Now the seller and the buyer each see their own signers' links and addresses, and only Cypress staff see every party's. The seller can still email the buyer their link at the address on file (Post-sale's Email their link, `resend_signing_link`); a new address for another party's signer is Cypress staff's to set (`correct_signer_email`).
- Pay Now and the new action share one function (`lib/commission-checkout.ts`): the amount from Cypress's records, part payments clamped, the duplicate-payment guard, the ACH fallback, the save-card consent. An app's sheet stays put when the card goes through (`redirect_on_completion: if_required`).
- Fix: Deal Setup keeps each side's suggestions from the last deal until that side picks or confirms. They were dropped for both sides as soon as either side touched the sheet, so the seller confirming took the buyer's title address and transporter away (in Cypress and in Grove). The "reuse the last deal's setup?" banner shows only when there is something for your side.
- Fix: Post-sale on an older closing waiting on the buyer's signature shows the seller the buyer's signer again (roster, Signed in ink instead), without the buyer's link. It read "waiting for the Bill of Sale to be approved" since the signing-links fix.
- Fix: the marketplace's make filter matches any case. Cars saved as "CHEVROLET" or "NISSAN" (as VIN decoding writes them) were missing from every search by make, Grove's and agents' included (`search_marketplace` with `make`).
- Fix: an offer Cypress staff make for an organization names that organization's own person as the buyer (its first admin or fleet manager), so the bill of sale is signed at the buyer, not by staff; the audit still says Cypress made it.
- A deep link carries the Deal Setup tab and staff's off-platform form on (`/go/...?tab=deal-setup&offPlatform=1`); it dropped every parameter before. Deal Setup opens the off-platform form for staff from that link.
- Email their link is a tinted button, not a white one.
- Cypress staff acting for the seller may pay the commission from Grove (as Pay Now lets them); staff acting for the buyer are refused.
- Docs: the closings developer guide says how to pay the commission from Cypress's own app, that `closing:write` also fills and confirms Deal Setup, and that the commission invoice is a call of its own (it said the API returns no invoices). OpenAPI 1.11.0 with its "Changes in 1.11.0" block; the MCP servers report 1.11.0 (they still said 1.9.0).
Valuations: the year ahead
- Every value carries its next twelve months. An estimate (`estimate_value`, `POST /api/v1/valuations/estimate`, and the front door's price) now answers `curve`: the car's value each month for a year on Cypress's own depreciation curve for its class (car, SUV, truck or van, from the decoded body, else the body most cars of that model have on Cypress), with what the next month and the next year of waiting cost. Null without a value or a model year.
Planning: what holding a car costs
- The cost of waiting, through the API. `GET /api/v1/planning/vehicles/{id}` (`get_plan_vehicle`) now answers `economics`: every other week, the car's projected value and what you end up with if it is released that week; the weeks it sells best and whether now is in them; its value's change after 1, 3 and 6 months; what one more month costs in value and to run; and whether the window stands on your operating data or on the value's decay alone. The verdict carries its `reason`. These are the figures the Planning page's verdict was already built on; the API left them out.
- Grove may move a car into a sell window (`planning:write` on Grove's connection), for Sell or hold from its Value card (Grove's next round). An existing connection gets it at its next sign-in.
Pricing: one floor default, and rules that list only ready cars
- A pricing rule lists a car only when it is ready. A rule whose outcome is to approve and list now checks what the Launch button checks: the car's location state, mileage and condition, with the rule's price as the asking price. When something is missing nothing moves, and the answer names it. Before, a rule listed cars with none of these.
- The preview says it first. Applying the rules to many cars without confirming (Grove's drafts card, `apply_pricing_rules`) now names what a car is missing (`missing`) and the offer lines the rule would set (`computedAutoAcceptAbove`, `computedAutoDenyBelow`), as the single apply already did.
- One floor default everywhere: wholesale +2%. Cedar's pricing guidance (the insights `suggestedFloor`) and the vehicle form suggested +5%; they now suggest Cypress's appraisal default, +2%, in one shared place (`DEFAULT_FLOOR_PCT`). The vehicle form keeps −5%, wholesale and +5% as choices, with +2% marked suggested.
- Fix: Floor vs Valuation reads as Cypress compares it. The condition (pricing rules, automations) compares the floor's share of the wholesale value (1.2 is a floor 20% above it), but its editor saved what you typed as a difference (20 became 0.2), so a condition meant as "20% above" matched almost every car with a floor. The editor now asks for the floor as a % of the valuation (120 for 20% above) and shows the worked example that way. A read-only check (`apps/web-v2/scripts/checks/floor-vs-valuation.ts`) lists the rules and automations saved the old way: on the local copy, Cypress's own "Notify floorprice outliers" (0.2, which emails the Cypress Managers about every floor). Changing saved values is left to a person.
Cars: condition checked, stuck drafts, and what runs by itself
- Condition checked in person. Cypress now keeps who looked at a car and when (`conditionChecked` on `edit_vehicle`, people only; `conditionCheck` on the vehicle's read). Listing readiness warns, never blocks, when a car has a condition nobody checked and no inspection. Grove's own record of this (since 1.0.0) stays as its fallback; Grove now sends it to Cypress too. Migration `20261101060000_vehicle_condition_checked` (two columns, additive).
- Stuck drafts on the work list. Mission Control has a new item, `stuck-draft`: drafts with a floor that were not sent to review in 7 days, one item with the cars. Grove's What do you need from me? shows each as a row that opens its Get it live card.
- Cypress's own automations, readable. Every automation says whose it is (`owner`), and `?owner=cypress|all` on the automations list shows Cypress's own that run on your cars, read-only (their published steps), even when the organization's Automations product is off (they run on its cars either way). Found on a walk: one of Cypress's own lists a draft as soon as its floor is saved, which Grove could only learn after the fact. Grove's Get it live card now warns before the first floor, and its drafts card lists Cypress's automations with no Pause.
Docs: Grove's journeys
- A Journeys section in Grove's docs, with a short walkthrough video for each: what needs you, what a car is worth and when to sell it, getting a car live, and finding cars to buy. Its overview says how every journey works: Cedar recommends and you decide, each journey ends by asking whether Cedar should keep watching (its playbook), and Cedar proposes rules from choices you keep making.
Fixed
- A car a pricing rule lists keeps its own price equal to the listing's. When a rule priced a car at launch, or a floor change re-priced it, the listing got the rule's price but the car kept none, so it was missing from searches and matches that read the car's price (Grove's included). A price someone set by hand on the car is kept.
Docs
- See it work, a new page in Grove's docs: the three stories, recorded live in Cypress and Grove, with Cedar explaining each step.
v4.0.0
2026-10-30Grove: your team, your Cypress Manager and Cedar, on your phone
Until now Cypress was one app, made for a browser on a desk. The people who sell and buy fleet cars spend their day in a yard, a truck or a lot, and we expect about nine in ten of them to reach Cypress from a phone. Grove is Cypress's second app: a phone-first team app where an organization's people, its Cypress Manager and Cedar work in one place, and where Cypress's decisions come to a person one at a time, with the work done. Grove keeps no record of its own about cars or deals: it reads and writes Cypress only through the public API, as the person signed in, so everything it does a partner's app could do too. Making that possible grew the API to 1.9.0: every member can now sign in to a connected app, a Cypress Manager can act for an organization from one, and offers and closings come with the facts worked out. It is the first time Cypress is two apps, which is why this is a major version. Grove 1.0.0 and Huddle 1.0.0, the conversations service both apps use, ship with it; each now has its own version and changelog.
Grove 1.0.0, the team app (new; not deployed yet)
- Today, cards and deal lanes. One brief each morning, with at most three decisions, each with its card. Every card button first asks Cypress what would happen (a dry run) and changes Cypress only when you confirm. Each closing has one room per side, where Sapele works the six steps.
- Selling and buying from a phone. Offers come with the facts worked out and a suggested counter. Add a car from a photo of its VIN, or import the week's spreadsheet: Cedar reads it as the Import screen does, asks only what it must, and lists every car before Run import. Buy targets come from one sentence, with daily picks that learn and offers on several cars at once.
- Zen or Huddle. Each person picks how Grove looks: Zen, one private conversation with Cedar that holds the brief, the decisions and every answer, or Huddle, the full app with the team (Today, Huddle, Goals, Tasks).
- Try Cypress. A visitor talks to Cedar with no account and gets a car's value, a VIN read or the market first. An email and its code then save the visitor's own individual Cypress account (see below).
- Cedar and its agents. Sakura (sales), Sequoia (market and plans) and Sapele (closings) work with Cedar. Cedar answers in about 2 seconds, shows Why Cedar said this, and learns from Correct Cedar. Teach Cedar sets a level per rule, and Pause all stops every agent.
- Installs like an app. Grove has its own icon on the home screen and push notifications. Profile › This device installs it and checks for a new version, and Grove says when one is ready.
Huddle 1.0.0: one set of conversations, in Cypress and in Grove (new)
- Huddle is its own service, with its own version. Conversations, files, read marks, the Inbox and web push live in Huddle, a headless service Cypress and Grove both call with their own keys (people and organizations keyed by Cypress's ids). Each browser opens one live stream with a short pass. Its changelog is `apps/huddle/CHANGELOG.md`, and its version is on its `/health` and on every answer. Tasks live in Cypress: Grove makes, finishes and reads every task there, and keeps a copy for Cedar's notes, so a task finished in Cypress closes in Grove.
- Huddle in Cypress is Grove's Huddle. A new page in the sidebar, under Mission Control, shows Grove's own Huddle laid out for a computer: the rooms on the left (Rooms, Conversations, Direct), the room, its threads, real cards you act on (an offer, a closing step, Deal Setup: each shows what will happen and changes Cypress only when you confirm), Cedar and Talk. The first time, Cypress signs you in to Grove on its own (no page, no click) when your organization has connected Grove; otherwise Huddle says what is needed. Links to Cypress open in Cypress. A Cypress Manager sees the organization they act for, or, with none chosen, their organizations with what is unread in each.
- Direct messages. Two people of an organization, or a person and their Cypress Manager, privately: one room per pair, every message in the other person's Inbox and on their lock screen (unless they turn Direct messages off). The organization's Cypress Manager is always in Direct. In Cypress, Huddle in the sidebar shows the number of what is for you: unread direct messages, your room with Cedar and mentions, across every organization you are in.
- Conversations, not channels. Huddle's rooms for a topic, a car, a buy target or a goal are called conversations, in Cypress's Huddle page, in Grove and in the Huddle guide. The API keeps its names (`create_huddle_channel`). Cypress's sales channels keep theirs.
- Signing in to an app Cypress made, inside Cypress (Connect Cypress `prompt=none`): a member, or Cypress staff acting for the organization, of an organization that connected the app is signed in with no page, under that connection; anything a person must decide comes back as the reason (`consent_required`, `login_required`, `account_selection_required`, `interaction_required`). Never for an app Cypress did not make.
- API and MCP (1.9.0). New scopes `huddle:read` and `huddle:write`, and 12 operations, each also an Operate tool in the new `huddle` toolset: `list_huddle_rooms` (`GET /api/v1/huddle/rooms`, `view=mine` for every organization), `get_huddle_room`, `list_huddle_messages`, `post_huddle_message`, `edit_huddle_message`, `delete_huddle_message`, `react_to_huddle_message`, `join_huddle_room`, `create_huddle_channel`, `add_huddle_members`, `open_huddle_direct`, `list_huddle_inbox`. Every call is made as the person or agent the token acts for and sees what they would; an agent reads, posts and reacts, while joining, opening channels or direct messages and adding people are a person's; an API key is refused (`403 PEOPLE_ONLY`).
Individuals and businesses: one set of account rules (new)
- A person's own account, from Grove's front door. A visitor who proves their email with a 6-digit code from Cypress's sign-in gets their own Cypress account: an individual one, named after them and active at once. It buys like any buyer. Once the email is proved, it values and lists cars within its caps: 10 cars listed and 10 valuations in total, 3 people, and 60 answers a day from Cedar. Marketing campaigns stay off until Cypress turns them on, then 500 emails a month. Email-only offers made earlier from the same address join the account. First-party apps only: the grant `urn:cypress:front_door_account`, with the code from `POST /api/oauth/front-door-code`.
- Every account kind in one table. What each kind of account may do, and its caps, live in one place in code. Admin › Settings › Account rules edits them for every individual or every business. An organization's Features and limits card (Admin › Organizations, Customers) overrides them for that one, and shows what it has used. A business keeps every feature with no caps, as today. - Every check reads the result: the API, valuations and listings, team invites, API keys, pricing rules, app connections and marketing. The API's refusal is `ACCOUNT_RULES`, and `account` (kind, features, limits and used) is on `GET /api/v1/organization` and on every token. - A scheduled send past the cap, or with campaigns off, pauses the campaign and tells its owner (`MONTHLY_EMAIL_CAP`, `MARKETING_OFF`). - MCP `get_account_rules`, `set_account_rules`, `get_organization_account_rules`, `set_organization_account_rules` (staff); `ORGANIZATION.UPDATED` with change `ACCOUNT_RULES`.
- Become a dealer. An individual with a proved email asks in Settings › Become a dealer, with its business: legal name, EIN, address, phone and website. It then finishes the checklist every dealer does: the W-9 and dealer license in Documents, and a billing method in Billing. Cypress staff see the request under Admin › Customers › Dealer requests and on the customer's page, and Approve stays off until the checklist is done. Approved, the same organization becomes the business, keeping its cars, offers and sales. Declined, the person reads why by email and in Settings, and may ask again. API 1.9.0: `get_dealer_request`, `request_dealer_account`, `get_organization_dealer_request`, `decide_dealer_request`; `ORGANIZATION.UPDATED` with change `DEALER_REQUEST`.
- Every front-door person in Cypress's book. A front-door account opens an onboarding deal (Inbound, at Interested) with the person as its contact. It carries a note pinned by Cedar with what it learned in the chat (fleet size, what they do, the car), each fact stated or assumed. The deal is won when the person becomes a dealer.
Valuations with a confidence level (new)
- How sure a value is, and what would make it surer. Every value says how sure it is: High, Good, Fair or Rough. That is the lower of what was given (the VIN with miles, condition and location; the VIN and miles; the year, make, model, miles and location; or the year, make and model) and the source the value came from. Each value says why, and names the better valuation to ask for.
- One estimate, Black Book first. Each source is tried only when every one before it had nothing: 1. Black Book, looked up now: with a VIN, while the account has a valuation to spend. Cypress staff spend none. 2. Black Book's earlier values: this VIN's, within a year, then similar cars'. 3. What similar cars sold for on Cypress. 4. A model's rough estimate. Every other source that agrees within 10% lifts the confidence one level: to at most High with the VIN, and Good without it. New action `estimate_value` (`POST /api/v1/valuations/estimate`, 1.9.0); `lookUp: false` only reads, never making a live Black Book call. `appraise_vin` stays the VIN-only Quick Appraisal.
- Black Book against Cypress's own sales. Every Black Book value is set against what the same model (else the same make) sold for on Cypress in the last year, under the price floors (five sales from two sellers). The answer gives Black Book's own figure, and how Cypress's sales moved it.
- Someone else's car is a range. A car's earlier Black Book value, read by anyone but its owner, is a range (±4%, rounded to $500), never its figure, date or miles.
- Grove's front door values from Cypress's data. Before any email, a visitor's value comes from Black Book's earlier values, Cypress's sales and a rough estimate (`POST /api/oauth/front-door-price`, first-party apps only), and never from a live Black Book call.
Cedar quality (Admin › Cedar quality, Cypress staff)
- Cedar quality: Cypress staff can see how Cedar performs and judge its flagged answers, and Cedar learns from the verdicts. A new staff page, Admin › Cedar quality (Cypress Managers and platform admins), has two tabs. Benchmarks shows Grove's benchmark runs of Cedar: the latest run (setting, share right, the time where the slowest tenth of answers starts, gates passed or not), two trends over runs (share right; slowest tenth in seconds), the latest run of each setting side by side, and every question of a run with its answer, marks, errors and time. Audit is the queue of answers Grove flagged (a live check failed, a person corrected Cedar, the judge scored it low, or picked at random), each with what Cedar read to answer: mark it Right, Wrong (say what the right answer was) or Dismiss, and tick Teach this org or Add to benchmark. A verdict is final and recorded in the organization's audit trail; decided answers are listed below the queue.
- Verdicts come back to Cedar. Every ten minutes Grove sends its flagged answers and reads the verdicts back. A Wrong verdict with Teach this org becomes a team lesson Cedar follows in every later answer for that organization, shown in Grove as taught by its Cypress Manager (the page keeps who decided); Add to benchmark adds the question to Cedar's benchmark. Benchmarks run only in sandbox organizations, never with a customer's data.
- The API behind it, for Cypress's own apps only. Grove sends benchmark runs (`POST /api/v1/agent-quality/benchmark-runs`, MCP `record_agent_benchmark_run`) and flagged answers (`POST /api/v1/agent-quality/audit-items`, `record_agent_audit_items`), and reads its organization's verdicts back (`GET /api/v1/agent-quality/verdicts`, `list_agent_verdicts`), with the new scope `agent-quality:write` on its own token. An API key cannot hold that scope, a partner app cannot be registered for it, and these endpoints refuse every other credential (`403 POLICY_DENIED`). Cypress staff have the same reads and the verdict with a CM token: `list_agent_benchmark_runs`, `get_agent_benchmark_run`, `list_agent_audit_items`, `decide_agent_audit_item`. A run sent again replaces itself; an answer sent again only gains flags, and one already decided never reopens. New Operate MCP toolset `quality` (first-party apps only).
Signing in to apps (Connect Cypress)
- Every member can sign in to a connected app. Until now only the admin who connected an app could use it. A member of an organization that has connected the app now sees "Continue to {App} as {Person}" and signs in on that connection, with the scopes it asks for that the connection holds. If the organization has not connected it yet, the member is told so and the admin connects it once.
- A token never exceeds its connection. Each token is capped at the connection's current scopes, on every call and every refresh: an admin who narrows the connection narrows every member's token, and removing it ends them all.
- Choose the organization. A person in more than one organization picks which one at sign-in (`organization_id`), and `/api/oauth/userinfo` lists their organizations, with which are connected.
- Cypress Managers in a first-party app. A Cypress Manager signs in to a Cypress app (such as Grove) for one business customer and acts as staff for it, as with Acting for in Cypress: every call re-checks that they are staff, that the customer is active and that the app is Cypress's own. Partner apps never get such a token. `userinfo` adds `acting_for` and which organizations they manage.
- A person's confirmation travels with the call. A Cypress app acting for a person can send `Cypress-Person-Confirmed: true` on the one call the person confirmed on screen, which lets a fleet manager or admin accept or counter below the floor from the app. The audit trail reads "<App> for <Person> (confirmed in the app)". Partner apps, API keys and agents: ignored.
- First-party scopes. Scopes only Cypress's own apps can hold (today `agent-quality:write`) are never offered to API keys, dynamic or metadata-document registration, partner apps, agent templates or sandbox keys.
API and MCP 1.9.0: offers and closings with the facts worked out
- Offers and closings come with the facts worked out. An agent reading offers or closings used to do the date math and the ranking itself: which offer is oldest, how far under the floor, which to answer first, how late a step is. On October 28 that arithmetic took Grove's Cedar 15 to 30 seconds per answer, and a fast model left to guess named the wrong offer "the oldest". Given the facts already worked out, the fast model answered in about 2 seconds and was right every time. So Cypress now works them out once, in one place, for the API, the MCP tools, Grove and in-app Cedar. Nothing was removed or renamed, and no default order changed. - Offers you received (`GET /api/v1/offers/received`, MCP `list_offers_received`): each offer carries `waitingHours`, `ofListing` (0.962 is 96.2% of the listing price), `underFloorBy` or `aboveFloorBy`, and, while it waits for your answer, `answerOrder` (1 = answer first: at or above the floor first, then the longest waiting, then the highest share of the listing price) with `answerWhy` in plain words ("above the floor by $600, waiting 20h, 96.2% of listing"). A new `summary` gives `count`, `answerFirst`, `oldest`, `closestToListing` and `underFloor`, each by offer id, so two offers on the same model stay apart. Both cover every offer waiting for your answer that the search and vehicle filters match, on every page and whatever the tab or sort, so the one to answer first is never only the first page's. - Offers you made (`GET /api/v1/offers/made`, MCP `list_offers_made`): an offer with a live counter from the seller carries `counterHoursLeft`, the whole hours left to accept it, never below 0. - Closings (`GET /api/v1/closings` and `/closings/{id}`, MCP `list_closings`, `get_closing`): each open step carries `lateHours`, `daysLate` and `late` ("8 hours late", "2 days late") once its deadline has passed, however little, or `dueInHours` and `dueInDays` while it is ahead. A step 8 hours past due reads late, never "due today". `urgency` says it for the current step with whose turn it is, and the list's new `summary` names the most overdue closing of all, whoever's turn it is, and the one to act on first yourself. `sort=urgency` puts your turn first, then the longest past due, to the millisecond. MCP lists keep `urgency` and `summary` in their summary view, where the steps are left out.
- The buyer's record with you. Each offer you received (and the Seller Hub's list) carries `buyerRecord`: purchases from you, the last one, and the average days to pay, from closings between your two organizations only.
- What the buyer saw. `conditionAtOffer` keeps the grade, the latest inspection, the ARVIS score and the damage count at the moment of the offer (never cost or value), on offers made and received.
API and MCP 1.9.0: vehicles and inventory
- Comparable sales. `GET /api/v1/vehicles/{id}/comps` (`get_vehicle_comps`) and `GET /api/v1/vehicles/comps` (`find_comparable_sales`): the same model within a year either way, similar miles, the last 12 months. Your own sales are listed one by one; other sellers' only as a range rounded to $100, and only when there are at least 5 sales from at least 2 other companies. With a summary and the Black Book value.
- Keys, damage note and damage lines. `add_vehicle` and `edit_vehicle` take `keyCount` and `damageNote`; new `list_vehicle_damages`, `add_vehicle_damage`, `remove_vehicle_damage` (area, description, severity, an optional picture). People only.
- The ARVIS inspection on the vehicle read (`arvisInspection`: grade and score, date, damages, repair estimate, a link into ARVIS), and engagement on each car (`engagement` HOT, WARM or COLD while on the market, `listedDays`, `views14d`, `saves14d`, `offers14d`), with `?engagement=COLD` to filter. Every car on the marketplace carries `engagement` too, whoever sells it (`search_marketplace`, `get_marketplace_vehicle`): the level only, another seller's counts stay theirs.
- Undo an archive within 24 hours. `POST /api/v1/vehicles/{id}/archive/undo` (`undo_vehicle_archive`): a fleet manager or admin takes back their own instant archive, unless a Cypress Manager has reviewed it or the car changed since. A car restored to listed is published again. The archive request's dry run now says which path it would take and why.
- Location aliases. A saved location takes other names (`aliases`), so "DEN" or "Denver yard" finds it, in the CSV import and the API push alike.
- Staged imports. `import_vehicles` with `mode: "stage"` keeps the dry run with each row's exceptions (duplicate VIN, mileage drop, unknown location, already sold, archive of a live deal). A person keeps or skips each row, maps a location (and can keep it as an alias) or fixes the miles (`decide_import_row`), then applies it (`apply_import`). Events `VEHICLES.IMPORT.STAGED` and `VEHICLES.IMPORT.APPLIED`.
- Read a fleet spreadsheet. `POST /api/v1/imports/read` (`read_import_file`) reads a CSV, TSV or Excel file as the Import screen reads it, with the same column matching (the header, then the values), now shared with the screen. Each row comes back in `import_vehicles`' shape. The answer says how sure each column's match is, what each column holds, and which useful fields no column fills. Send `mapping` to set a column the match got wrong. A file with no VIN column answers no rows and `missing: ["VIN"]`. Saves nothing.
- What the pricing rule would do with a draft. `apply_pricing_rules` without `confirm` now judges a draft too, as if it were in review (nothing is written), and every answer names the rule that matched (`ruleId`, `ruleName`, null when none did). Grove's drafts card shows it before Go live.
- The dashboard's numbers. `get_fleet_metrics` adds `tiles`: the dashboard's seven tiles in its order and words, with their real trend.
API and MCP 1.9.0: pricing, buying, organization and billing
- Edit a pricing rule. `PATCH /api/v1/pricing-rules/{id}` (`update_pricing_rule`): an admin or fleet manager edits their organization's own rules. People only, with a dry run, audited.
- Floors from the wholesale value. `set_floor_prices` with `mode: wholesale-percent` sets each floor to the latest wholesale value plus a percent. A valuation older than 30 days is skipped (`VALUATION_STALE`) unless `allowStale`.
- Picks that learn. `dismiss_marketplace_pick` and `restore_marketplace_pick`, with a reason (price, mileage, model, location, other). Picks never show a dismissed car or your own organization's, rank by what you bought, put lookalikes of dismissed cars lower, and say why each one is there.
- A buyer's usual. `get_purchase_profile`: price, age and mileage bands, makes, models, body styles, states and buying pace, from the organization's own purchases in the last year.
- Direct links to a buyer. A share link can be addressed to one buyer organization (`recipientOrgId`), which is told (`MARKETPLACE.SHARE.RECEIVED`, never with the floor); `open_share_link` opens one as that buyer, cars shown only by link included, and offers through it work from an app acting for a person.
- The organization's Cypress Manager on `get_organization` (`accountManager`).
- Delegation with an end date. `set_cypress_delegation` takes `expiresAt`. Admins only.
- Bank transfers held for a person. `list_unapplied_bank_transfers`: each transfer short, over or unmatched, with its candidate invoices. No API records a payment.
- A task can belong to a conversation. A task made from a message ("retake the Tahoe photos") can belong to the conversation it came from, its Huddle room and, optionally, the message, with no car, contact or plan behind it. `POST /api/v1/tasks` and `PATCH /api/v1/tasks/{id}` take `conversation: { roomId, messageId }` (MCP `create_task`, `update_task`); `GET /api/v1/tasks?conversationRoomId=` (`list_tasks`) lists one room's tasks; the task reads it back as `links.conversation`, and so does every `TASK.*` event, which a subscription can also filter by the room id. In Cypress's task lists such a task shows From a conversation in Huddle, a link that opens the conversation (or the message's thread) in Cypress's Huddle.
- A task can go to the organization's Cypress Manager. A member could give a task only to another member; the org's own Cypress Manager works in its huddle, so a task can now go to them too (`assigneeUserId` on create, patch and reassign). Any other Cypress staff is still refused.
Mission Control
- Two new staff items, in Cypress and for a staff app. "Reopen step N?" when a Deal Setup change made paper already issued wrong, and money from a bank transfer held short, over or unmatched. A Cypress app with a staff token reads the board a Cypress Manager acting for that organization sees.
- Set floors counts drafts. "Missing floor" now counts draft cars without a floor (planning stock aside), and Set floors opens the fleet unfiltered.
- A receipt for a pricing rule's work. Once a day, for each rule that approved and listed cars, the cars and the price it gave them.
Changed (API and MCP 1.9.0)
- `quarter.spent` counts only cars already bought. On `GET /api/v1/buy-planning/plan` (MCP: `get_buy_plan`) it used to add the closings still in progress, so it read higher than the Buy plan page's "spent" and `budgetPct` looked further along than the plan was. It now matches the page and `progress.spent` on the quarter: money on vehicles bought this quarter. The old figure, bought plus in closing, is `spentOrInClosing` (with `spentOrInClosingPct`), the number behind the Plan tab's "Spent or in closing" tile and the over-budget step. An integration that warned on `spent` nearing the budget should read `spentOrInClosing` to keep warning as early as before.
- An expired delegation is off everywhere. Once its end date passes, a grant to Cypress reads as off in every place that checks it (offers, buyer actions, vehicle writes, imports, launches, Planning, Cedar, the Acting-for bar, staff screens), and is stored as off.
- Staff bulk launch needs delegation. Launching an organization's approved cars as staff needs its "Act on its behalf" grant; if one organization in the batch lacks it, nothing launches (`403 NOT_DELEGATED`).
- More closing events, from every path. `CLOSING.STEP.COMPLETED` now fires however a step completes (the last signature, ink or canvas signing, commission paid or deferred, title accepted, pickup, off-platform attestations), once each. Signed paper documents send `CLOSING.DOCUMENT.SIGNED`, a new incident message sends `CLOSING.UPDATED` (`INCIDENT_UPDATED`) with its `messageId`, and a rollback names the step it went back to. Each closing read carries its Deal Setup (`dealSetup`).
- Plan, buy and insight events reach the event log without a subscription. `GET /api/v1/events` now carries `PLAN.*`, `BUY.*` and `INSIGHT.*` even when no webhook lists them; delivery still goes only to subscriptions that do. A buy plan match names its target (`targetId`).
- Batch apply pricing rules works like the single apply. Each car is judged by Cypress's rules and its own organization's only (a Cypress Manager's batch used to mix every organization's rules); cars in other statuses are named, not moved to draft; a car priced at $0 is never listed.
- Planning and Buy planning start on for new organizations. Organizations created from now on have both; existing organizations are unchanged.
Fixed
- A confirmed Deal Setup no longer reads "your turn" on the fee. The commission step waits on whoever has not confirmed Deal Setup (the seller first, then the buyer), in the closings hub, the closing page, Sapele's notes, the API's `turn` and `yourTurn`, the `CLOSING.STEP.*` events and Grove. Before, a seller who had confirmed read "Your turn · overdue" while the buyer had not. A reminder on that step tells the buyer about Deal Setup, never about a fee they do not see.
- The dashboard reads one set of numbers. The first view, the period switch and the API now share one computation. Before, Northwind's Wholesale GMV read $1.7M on load and $1.0M after a click, and 365 days silently read as 30. The "Wholesale GMV" trend chart, which drew no real data, is now a real "Vehicles sold" per day; planning stock is no longer counted in Wholesale GMV.
- Bidders hear about condition changes. A change to a car's condition, grade, keys, damage note or damage lines now sends its update notice to the seller, Cypress and every buyer with an open offer. Before, neither the app nor the API told them.
- The gate pass can be filed from an app. No app could file the gate pass countersigned by the transporter, so a pickup worked from an app could never complete.
- The CSV import resolves locations in the real run, not only in the wizard.
- A staff archive rollback publishes the listing again that the archive had taken down.
- A button can be a link with an icon. The shared button lost its link when given an icon (`asChild`); nothing used it until Huddle's links.
- An automation that lists a car launches it. An automation's Change status to Listed wrote the status alone, so the car read "Listed" with no Cypress listing, no offer rules and no listing place counted (an individual's 10 could be passed by a flow). It now goes through the launch the pricing sweep uses (the worker's `listByAutomation`): the listing published at the listing price, the offer rules on, the place counted, buyers' alerts and `LISTINGS.ACTIVATED`; refused, with the reason in the run, when the car has no listing price or the account has no place left.
- Marketplace search finds every word. "Toyota Camry" found nothing (it was one phrase, matched in one field), and "SUV" or "sedan" never matched. Now every word must appear in the make, model, trim or body, in the API and in the marketplace's search box.
- The shared appraisal says Black Book when it is Black Book. Quick Appraisal and Grove's add-a-car labeled Black Book answers "Heuristic estimate". An appraisal without a ZIP or a decoded make answered 400.
- Security: email-only offers join an account only once its email is proved. The private buyer sign-up claimed them for whatever address was typed.
- Security: an incident message can only attach files from its own closing. The in-app route accepted an attachment filed for a different closing. Incident reports and messages also take up to 5 photos.
Docs
- A Grove chapter, phone first: overview, quickstart, what you can do by role, 12 how-to guides (choose Zen or Huddle, install Grove and keep it up to date, answer an offer, import a spreadsheet, get drafts ready and take them live, talk with Cedar, work a closing, buy cars, follow the quarter's plan, tasks and hand-offs, teach and correct Cedar, act for an organization) and 4 reference pages (a glossary of about 70 terms, Cedar and the agents, notifications and quiet hours, data and privacy). Every guide's screenshots are phone captures in a phone frame.
- Cedar quality in the staff manual, and Agent quality for developers.
- Talk with your team in Huddle (Platform, how-to) and Huddle for developers, with the new scopes on the organization page and the 11 tools on the MCP and app-actions pages.
- The developer and apps-and-agents pages mark every 1.9.0 addition, including member sign-in, staff tokens and the person's confirmation.
- Valuing a car you have not added (Pricing › Valuations), front-door deals (CRM, deals and pipeline), Becoming a dealer and account rules (roles and navigation), and the errors, organization, webhooks, app-actions and MCP pages for the account rules, dealer requests, `estimate_value` and `read_import_file`.
Not yet
- Grove is not deployed. Owed per environment (set up by `scripts/grove-launch.mjs`, runbook `docs/plan/grove-huddle-launch.md`): Railway services for Grove and Huddle, each with its own Postgres, Huddle's R2 bucket, a VAPID key pair, the domains `staging.grove.getcypress.ai` / `grove.getcypress.ai` and `staging.huddle.getcypress.ai` / `huddle.getcypress.ai`, and Grove registered as a first-party app (`POST /api/admin/oauth-clients`) with every scope Grove asks for, `organization:write`, `agent-quality:write`, `automations:read` and `automations:write` included.
- Grove's frame and Huddle are not deployed. Grove allows only Cypress to show it in a frame (`CYPRESS_URL`); `NEXT_PUBLIC_GROVE_URL` on web-v2 must be Grove's address. Huddle is owed per environment: a Railway service with its own Postgres and Redis, an R2 bucket, the VAPID keys, an app key each for Cypress and Grove (`HUDDLE_APP_KEYS`), the stream secret, Grove's webhook and secret (`HUDDLE_WEBHOOKS`), and on web-v2 `HUDDLE_URL`, `HUDDLE_APP_KEY` (and `HUDDLE_PUBLIC_URL` when browsers reach Huddle at another address) and `NEXT_PUBLIC_GROVE_URL`. Until then the Huddle page says conversations are not set up.
- Cedar answers people who use Grove (now everyone who opens Huddle in Cypress, signed in to Grove on their own). Someone who only writes through the API gets a line asking them to open Huddle once.
- Migration `20261028190000_task_conversation` (a task's conversation, two columns on `CrmTask`) is applied by hand before the deploy: the worker reads every column of the table.
- Migrations by hand before the push, in this order: `20261030020000_front_door_accounts`, `20261030040000_individual_accounts`, `20261030050000_account_rules`, `20261030060000_dealer_requests`, `20261030070000_valuation_imports` (staging already has this one, recorded), each followed by `migrate resolve --applied`. Grove's and Huddle's databases are new: their migrations are listed in their changelogs.
- Black Book's history in production: after the deploy, run `scripts/valuations/bb-history.ts export-sql --calls` and `import --origin calls` once. The values Cypress already paid for then count as earlier values.
- Owner decisions: whether an answer from a person's own room with Cedar goes to staff audit when its check fails or it is corrected (today it does; random samples never come from it), and whether any existing organization also gets Planning on.
v3.9.3
2026-10-26Answers given while generating can be changed or cleared
Fixed
- A gap answered on the generate form stays editable, and can be cleared. A pickup street, contact or phone typed into the form's blank fields was saved to the deal and then shown read-only, so a wrong entry could not be taken back. On October 26, browser autofill filled the owner's own street and phone into a gate pass's pickup fields and they stuck. Now a value a person entered on the form stays editable, with a clear button beside it that leaves it blank on the deal. A value copied from the record chosen in Deal Setup (a saved pickup location, bank account or lienholder) stays read-only on the form and is changed in Deal Setup, as before.
- Browsers and password managers no longer fill the generate form. Its fields are deal facts, not the viewer's details: Chrome ignores `autocomplete="off"` on address-like fields, so they now carry a token it does not fill, neutral names, and the password managers' ignore flags.
v3.9.2
2026-10-26A completed step is reopened only when a Cypress Manager says so
Fixed
- A Deal Setup save no longer rolls a closing back on its own. Until now, changing a fact that paper on a completed step printed reopened that step at once: the paper was set aside, its signatures voided, every later step locked again. On October 26, filling in a blank pickup address while generating a gate pass reopened a signed Bill of Sale that had printed that box empty, and the closing went from the gate pass back to step 2 (the funds confirmation undone, the buyer asked to sign again). Now the save only names the step, and a Cypress Manager decides: - a Cypress Manager who makes the change is asked on the spot (Reopen step N or Keep as issued); - a seller or buyer who makes it is told a Cypress Manager decides, and the Cypress Managers are notified; - on the closing, a completed step whose paper no longer matches the deal shows what it printed against the deal now, with Reopen step N for Cypress Managers.
- Only a value the paper printed, now different, counts. Filling in a box the paper left blank, or changing a value and changing it back, never asks for a step to be reopened. Facts a document does not print never did.
- The Deal Setup warning before a save says what the save does: it never reopens a step.
API and MCP
- `update_closing_deal_setup` (`PATCH /api/v1/closings/{id}/deal-setup`) no longer rolls back: `rolledBack` is always `null`, and `reopenSuggested` names the completed step whose issued paper the change made wrong (`toStep`, `stepKey`, `documents`, `facts`), for a Cypress Manager to decide. The `CLOSING.UPDATED` event with change `DEAL_SETUP_UPDATED` carries `reopenSuggestedTo` instead of `rolledBackTo`.
Activity
- A rollback undone by a Cypress data restore shows as Rollback undone in the closing's activity.
v3.9.1
2026-10-26The gate pass says where to pick the car up
Fixed
- "Vehicle's current location" fills the gate pass. Picking it in Deal Setup used to print "Vehicle location" as the place, with no contact, phone or hours, and no address at all when the vehicle was recorded only as a city. When the car stands at one of the seller's saved locations (matched on the vehicle's location name, as the PO already was), that location's contact, phone, email, hours, advance notice and PO now come with it, and its street too when the vehicle has none. The place is named after the saved location or the vehicle's location name, else its city. Deals already set up keep their copy until the pickup is chosen again.
- The Cypress gate pass prints the city, state and ZIP of the pickup on one line under the street ("Denver, CO 80216"). It printed the street alone.
Added
- Several deal values on one line in the template designer. A row or field showing data from the deal takes more values on the same line, each joined by a comma, space, dot, slash or dash (+ Add a value on this line). A value the deal lacks drops out with its join. Every value on the line is a field of the template: a missing one is asked for when the document is generated. Templates sent through the API may carry it as `append` on a bound source.
v3.9.0
2026-10-23Your inventory, kept in step
A fleet customer's first API integration asked for two things the API made hard. Their feed has no photos, only a link to each unit's Manheim condition report, and the API had nowhere to put it; the report endpoint we did have was documented as a CARFAX/AutoCheck reader, which it is not. And they wanted to push their whole inventory and have Cypress take off whatever was no longer in it. Both now work. Along the way the archiving rules changed for everyone (owner and CEO): a seller's archive waits for a Cypress Manager only when a buyer is involved.
Archiving: Cypress reviews only when a buyer is involved (every door)
- Archived at once: planning stock, and any vehicle with no open offer and no sale in progress (draft, in review, approved, pre-listed, listed, on hold, or Under Offer with no offer actually open). The same cleanup an approval runs happens right away. Applies to the fleet panel, Seller Hub, the CSV import (archive rows and "vehicles not in this file"), the API (cancel, sold-elsewhere, planning departure) and the full-inventory push.
- A Cypress Manager decides, as before, when the vehicle has an open offer or a sale in progress (offer accepted, due diligence, ready to deliver): archiving would decline a valid offer or cancel a closing. A listed car pauses while it waits.
- Still visible to CMs: an archive applied at once is kept as an approved request (*Archived at once*) and lands on Archived Vehicles with its decision *Pending*, so an organization that onboards cars only to value them still shows up. Replaces 2.4.2's "every seller archive is reviewed".
- A completed sale is never archived: an archive of a SOLD car always goes to a CM, and the CSV import refuses the row. Archiving a car that is already archived (a double click, a repeated row) changes nothing and tells nothing twice.
- Seller Hub and the archive modal say up front whether the archive applies at once or goes for review, and the toast says which happened. The Import wizard's wording follows the rule.
- API (behavior change for existing keys): `DELETE /api/v1/listings/{id}` on such a car answers 204 (was 202); `POST /api/v1/vehicles/{id}/disposition` and `/departure` answer 200 with `archived: true` (were 202); webhooks get `VEHICLES.DISPOSITION.RESOLVED` at once with `automatic: true`, with no `STAGED` before it. A staged answer says why in `reviewReason` (`OPEN_OFFERS`, `SALE_IN_PROGRESS`).
Inspection reports from the push
- `inspectionReportUrl` on every vehicle push (`POST`, `PUT`, `PATCH`, bulk, and the MCP tools `create_vehicle`, `update_vehicle`, `push_vehicles`). A Manheim condition report is read after the push: its grade becomes the vehicle's condition (unless a finalized ARVIS inspection set it), and its damages, title, keys, tires and photos are added. The same link again changes nothing, so a daily feed can send it every time; a new link replaces the report; a link whose report failed is tried again at most once a day; any other link is kept as a link. The push answers `inspectionReport` (`QUEUED`, `UNCHANGED`, `LINK_ONLY`); a Manheim link without its disclosure id is a warning and the vehicle is written anyway.
- The vehicle's read carries `inspectionReport`, and `VEHICLES.UPDATED` with `changedFields: ["inspectionReport"]` carries the same shape when the report's data lands, when its photos land, and when it fails, whichever door attached it. A photo import that fails for good now marks the report FAILED instead of leaving it importing forever.
- Fix: a re-push keeps an inspection's grade. A replace (`PUT`, `mode=upsert`, a planning upsert) that sends no `condition` used to clear the condition an inspection had set, every day for a daily feed. It now keeps it. This also applies to the Manheim-compatible units.
- Fix: the report endpoints said "CARFAX or AutoCheck". `GET`/`POST /api/v1/vehicles/{id}/history-report` and their tools now say what they are. Paths and tool names unchanged.
The floor and saved locations on the push
- `floorPrice` on the push, the CSV import's rule: set at once on a vehicle not yet on the market; on one on the market or in a sale, a price request a CM reviews (a daily feed re-sending the same floor files nothing new). The push answers `SET`, `UNCHANGED` or `STAGED`. A customer's "sell price" is the floor, as in the CSV import.
- Saved locations: a `location.name` that matches one of the organization's saved locations (any case) fills the address, city, state and ZIP the push left out, as the Import wizard does; a name matching none is a warning.
The whole inventory in one push
- `POST /api/v1/vehicles/bulk?removeMissing=true` (MCP `push_vehicles`): the push is the customer's whole inventory, and every active Sell vehicle it leaves out (not sold, not archived, not planning stock, whoever added it) is taken off by the archiving rules above. A car whose removal a CM rejected is not filed again until it is pushed again; an item refused for another field still counts as in the inventory; a pushed car with a request waiting, or already archived, gets a warning.
- The answer says where each vehicle stands: `removals` with each vehicle's `requestId`, `archived`, `statusBefore`, `statusAfter`, `paused`, `alreadyPending` and `liveDeal`, and the `kept` list.
- Larger than 500: pages go without the flag; the last page names every VIN held in `inventoryVins` (up to 20,000). Nothing is kept between calls.
- A truncated feed never empties a fleet: a push that leaves out more than a fifth of the active vehicles (and more than five) is refused whole (`409 MASS_REMOVAL`, nothing written) unless `allowMassRemoval=true`. A fifth, not half (owner): a removal with no buyer is archived at once, so the guard has to stop a broken feed before anything is written.
- `mode=merge` for a daily feed (`POST /api/v1/vehicles` and `/bulk`, MCP `create_vehicle` and `push_vehicles`): a VIN already held takes only the fields the push sends, as `PATCH`, and a new one is created. `mode=upsert` still replaces each vehicle with what is sent, clearing every field left out, so a feed of a few columns used to wipe the trim, transmission or interior color a CM had filled in, every day.
- `syncId` labels a push and is told again in its events; `dryRun=true` answers what each item and the removals would do, writing nothing (new on the bulk push).
Archive events say where the vehicle stands
- `VEHICLES.DISPOSITION.STAGED` and `RESOLVED`, from every door, also carry `statusBefore`, `statusAfter` (after the filing, or after the decision) and `source` (`api`, `api-sync`, `csv`, `app`), and `syncId` from a full-inventory push. The request remembers its door, so the decision tells it the same way. Manheim-dialect subscriptions get these events exactly as before.
- Fix: filing a request pauses a car only if it is still listed at that moment. A status read a moment earlier could put a car whose offer had just been accepted on hold.
API, MCP and docs
- Integrations API and MCP 1.8.0. New error code `MASS_REMOVAL`. New MCP tool `push_vehicles` (API keys only, marked destructive).
- New developer page: Pushing inventory: single and bulk pushes, how a feed's fields map, the floor and saved locations, inspection reports, and keeping the whole inventory in step. De-listing, Webhooks, Errors, Planning, the overview, and the manual's Archive & Dispositions, Update Requests and CSV Import pages follow the new archiving rules.
Also in this release
- Fix: "Open in ARVIS" and "Open in ORBIS" land on the page again. After the sign-in handoff, production sent people to a dead `localhost` address; both now stay on the address you came from. A guard test stops any route in the three apps from building a redirect that way again.
Not yet
- A re-push doesn't change a live listing's own prices (the embedded `listing` creates it once); the floor travels on every push.
- The push can't set a vehicle's lender (a feed's finance source).
- A CSV archive that waits for review doesn't pause a listed car, as the app's and the API's do.
- The Manheim-compatible dialect gets no new fields.
v3.8.0
2026-10-21Invoices your AP can book
Some fleet customers' accounts payable match every invoice to a purchase order, often one per branch, and book invoices by branch or by car. Cypress invoices now carry what they need: a PO on each location that flows through Deal Setup onto the invoice, an invoice line written the way the customer's AP reads it, and, for ACH and bank-transfer customers, one invoice per close, per location, per PO or per vehicle. Billing emails can go to several addresses.
PO numbers
- Settings › Locations: an optional PO number on each location (a new Billing section of the drawer), shown on the location's row. Up to 64 characters, trimmed; clearing it removes it. Cypress staff edit it the same way from the admin organization page.
- Deal Setup copies the PO from the pickup location, and the seller can correct it for the deal; the buyer never sees it. A car released from where it stands takes the PO of the saved location with the same name.
- PO required (set by Cypress per organization): the seller's side of Deal Setup cannot be confirmed without a PO, and once confirmed the PO can change but not disappear.
What an invoice says (Billing › Billing details › Invoice content)
- The invoice line is built from parts: vehicle, VIN, Unit #, plate, location, PO number, sale date, sale price, or any of the seller's own Deal Setup fields, in the order chosen. The default reads *Sale commission · 2021 Toyota Camry SE · VIN … · Unit 18842 · Fort Lauderdale · PO 4500012345*. "(Deferred)", "(Instant)" and the commission scheme's name no longer print.
- PO number and Location in the invoice header whenever the invoice has one of each, where accounts payable software looks for them.
- What an invoice printed is recorded on the commission once Stripe accepts it: a later change to a location or a deal never rewrites an invoice already sent.
- Cypress staff edit it on the admin organization page with a live preview from the organization's latest sale; the organization reads it on its Billing page.
Invoices at each close (ACH and bank transfer)
- Admin › Organizations › Billing › How they pay › Invoices at each close: all open commissions on one invoice (the default, as before), one invoice per vehicle, or one per location, PO number, state, fleet or Deal Setup field. Sales without a value share one invoice ("No PO number"). Paying per sale stays one invoice per sale.
- Each invoice has its own number and Stripe document and, on bank transfer, its own transfer reference. On ACH each is its own debit (the setting says what that costs).
- One email per close, listing every invoice with every PDF attached and a CSV for AP import. Reminders, overdue notices, receipts and the notices to the organization's people and Cypress staff also come once per close.
- A retried close never issues an invoice twice and re-issues only one that failed; a close cut short by a restart is completed by the daily job, and its email sent.
Billing addresses
- Billing details takes several addresses ("ap@company.com, controller@company.com"); every invoice, reminder and receipt goes to all of them. The first stays the one Stripe holds.
API and MCP
- Integrations API and MCP 1.7.0: `poNumber` on `GET`/`POST /api/v1/locations` and `PATCH /api/v1/locations/{locationId}` (`list_locations`, `create_location`, `update_location`); `GET /api/v1/billing/invoice-settings` (`get_invoice_settings`); for Cypress staff `GET`/`PATCH /api/v1/staff/organizations/{id}/invoice-settings` (`get_organization_invoice_settings`, `update_organization_invoice_settings`) and `billingEmailCc` on `update_organization_billing_details`. Additive; nothing changed for existing keys.
- Manual: Billing (invoice content, invoices at each close, several addresses), Closing process (the PO in Deal Setup), Roles and navigation (the PO on a location).
Not yet
- A bank transfer that pays only some of a close's invoices is applied oldest first. Matching it to the invoices the customer meant, and voiding and reissuing an invoice with a wrong PO, come next; until then a bank-transfer customer that may pay part of a close stays on "All open commissions".
v3.7.0
2026-10-21Platform activity
Cypress now knows who uses it and what they do. Every visit, sign-in and act is recorded, with who did it (the customer's own people, Cypress, or an agent) and how (by hand, in bulk, with Cedar, or through an agent), and a new staff page answers the CEO's questions: active users by side, the funnels from first visit to sale, every buyer and seller action, each organization person by person, the Cypress team's work, and who comes back. Actions carry their whole history from the first deploy; visits and sign-ins start today. The same figures reach the API, MCP and Cedar. PostHog had been losing every signed-out visitor since July 10; fixed.
Platform activity (Analytics › Platform activity, Cypress staff only)
- Its own row in the Analytics menu, after Platform: Platform is about sales and money, Platform activity about people. Eight tabs; the tab and the period (7, 30 or 90 days, or the quarter so far, in whole UTC days ending yesterday, against as many days before) live in the address.
- Overview: active users per day (weekday average), per week and in the period, for everyone, buyers, sellers, consumers and Cypress staff; twelve weeks of weekly active buyers and sellers; who did the work; and the path from a vehicle page visit to a purchase.
- Funnel: the buyer funnel (people), the seller funnel (organizations) and campaign email reach, each step with its rate from the step before.
- Buyers and Sellers: every action on the CEO's list against the period before, with people, organizations and who did it; the six post-sale stages counted deal by deal (passed, median days in the stage, waiting now); the most active organizations.
- Orgs and people: every customer organization, or person, active in the period or the one before, with quick filters for Mostly Cypress, Using agents and Went quiet. One organization, person by person: who opened Cypress on which days, what each did, and which Cypress managers and agents also worked on the account.
- Cypress team: what managers did for each customer (organization by kind of work) and Cypress's own work, the customers Cypress mostly runs, each manager's sign-ins, days and how they worked (by hand, bulk and imports, with Cedar, their own agent), and the agents on customers' accounts. One manager: their days and recent actions.
- Coming back: people or organizations by the month they were first active, the share still active each month after, who came back after a break, who went quiet, and each month split into returning, new and back after a break. Not Platform's Retention (selling organizations by GMV).
- Data sources: whether PostHog, Google Analytics and Cypress's own recorders are collecting in this environment (moved here from Platform).
- Demo and showcase organizations, Cypress staff and agents are never counted as customers. A change is never shown between a recorded period and an approximated one; the page says why.
Recording who uses Cypress, and how
- A visit a day: one row per person, organization and day, written from the session at most every 15 minutes, page views only (never a background call); sign-ins counted at sign-in; agents and tokens counted apart.
- Every audit row says how it was done (`via`: the app, bulk, an import, Cedar, an API key, an agent, a token, an automation, the system) and whether a Cypress manager acted on a customer's behalf (the Acting-for lens or a staff token, at that customer's organization only).
- Signing out clears the Acting-for lens. PostHog now knows signed-in people by their Cypress id (ids and flags only).
Counting it
- An hourly rollup (at :20) counts what happened each day: offers, counters, acceptances, post-sale steps, sales and purchases, uploads, listings, vehicle views and Cypress staff work, from the business tables. Its first run fills the whole history; a missed hour or a failed run is filled on the next one, and two runs never double a count.
- Sandbox vehicles, and sandbox dealers' offers on real vehicles, are left out at the source.
API, MCP and Cedar
- Integrations API 1.6.0: eight reads for a Cypress staff token under `/api/v1/staff/platform-activity/` (overview, funnel, a side, organizations and one organization, the team and one manager, coming back), each an Operate MCP tool (`get_platform_activity_overview` … `get_platform_activity_coming_back`). Additive; nothing changed for existing keys.
- Cedar reads Platform activity for Cypress staff (`get-platform-activity`), and offers three questions on the page. A customer's Cedar never has it.
- Manual: a new Platform activity chapter; Platform analytics lists its eight tabs.
Changed
- Platform analytics: the Action Funnel tab (a count per historical-import audit action) is retired, and Data sources moved to Platform activity; old links to either land there.
- The planning demo's reset also removes the demo organizations' activity rows.
Fixed
- PostHog lost every event from visitors who were not signed in since July 10: its proxy (`/ingest`) was not a public path, so the requests were redirected to the home page. Signed-in people's events and Google Analytics were not affected.
v3.6.0
2026-10-19Pay by invoice
A third way to pay Cypress, for a customer whose accounts payable pays invoices and never lets a vendor pull money: **bank transfer**. The same terms as ACH, one invoice a cycle with a due date, an account number of its own, and payments matched automatically. The billing screens are rebuilt around how an organization pays, Lago is gone for good, and the ACH cycle's long-standing faults are fixed on the way.
Bank transfer (pay by invoice)
- Switched on per organization by Cypress staff (admin org page › Billing › How they pay), never alongside ACH. The same terms as ACH: sales defer, and the cycle closes on the 14th and 28th.
- One invoice a cycle, due a set number of days later (10 unless agreed), emailed by Cypress to the billing email with the PDF, the account to pay and a transfer reference. Each customer gets its own US bank account number, the same on every invoice, for ACH credits and wires.
- Payments are matched automatically: by the reference, then the exact amount, then the oldest invoice, whole invoices only. A short, unmatched or extra amount is held and Cypress staff are told. A receipt follows by email.
- Reminders and lateness: a reminder three days before the due date; the day after it, the invoice is Overdue, by email and on the Billing page. Unpaid invoices count against the credit line. Lateness does not pause listings.
- No Stripe Invoicing fee: the Stripe invoice stays our accounting document, marked paid once the money is ours. Its PDF shows the real due date.
- Never sent without its document: if Stripe refuses the invoice, the email waits, staff are told, and the daily run and Check & settle try again.
Card, always
- Any open invoice can be paid now by card, whatever the method, an issued bank-transfer invoice included (Pay by card, the whole invoice). The transfer is then no longer expected; a card payment that lands after a transfer already paid is flagged to staff for a refund.
- A card is saved for automatic charging only under Per sale. ACH and bank-transfer organizations pay by card as a one-off and are never asked to save it.
Billing screens, rebuilt
- Admin org › Billing, four cards in this order: Billing details, Credit line, How they pay (one method at a time; choosing another says what happens first, or why it cannot yet) and Invoices & payments (owed now, money received and not applied, recent transfers, batches; Invoice now / Debit now).
- Your Billing page: one line that says what you owe and when, How you pay beside the credit line, and Billing details, which your organization's admins and billing admins can now change (tax exemption stays Cypress's).
- A waiting commission says how and when it is collected: ACH debit or bank transfer, and the day its invoice is issued, on the post-sale fee step, in Deal Setup, and in the invoice list ("Invoice Oct 28 · due Nov 7", "not due yet") and its panel.
Changed
- ACH debits move from the 24th to the 28th. Both methods close on the 14th and 28th (00:01 UTC), as the Billing page always said, through one cycle in the app; the worker's own ACH sweep is retired.
- Reaching the credit limit debits an ACH organization at once. Since September 1 it called Lago, failed, and did nothing.
- Integrations API: an organization on bank transfer reports `autoPay: NONE`.
Fixed
- ACH: the credit line is restored once a debit settles (listings stayed on hold after paying); the cycle sweep debited the gross amount, ignoring credit notes and part-payments, and made no invoice PDF; a retry could bill invoices already on an unpaid batch; Check & settle now restores credit too.
- Overdue is set when an invoice is late (nothing ever set it).
- Cycle dates are UTC days: the 28th no longer reads as the 27th east of UTC.
- Deal Setup no longer says a deferred organization's saved card will be charged on confirmation.
- A deferred sale already on a cycle invoice cannot be paid by card on its own, which would have collected it twice.
- The Lago usage-metering worker shared the billing queue with the commission-expiry sweep and could take its jobs and drop them.
Removed
- Lago, for good: every code path that could still call it, the worker's metering and customer sweep, and the invoice-PDF queue. Invoices from the Lago era keep the PDFs already stored.
Docs
- Billing and Payments & commission in the Platform Manual: paying by bank transfer, the new page, paying by card at any time. The Stripe runbook: the three collection paths, the set-up for each Stripe account, and testing it locally (`pnpm dev:bank-transfer`).
v3.5.0
2026-10-18The grand MCP release: Cypress is agent-ready
Cypress opens up to software. Everything a person does on the app's screens can now be done by an AI agent or a partner's app, through MCP or the REST API, under the same rules, with the decisions that belong to a person handed back to one. Agents get thirteen core tools instead of four hundred, a sandbox of their own that resets itself, and a documentation section written for them. The Integrations API goes to 1.5.0. Includes every fix prepared as 3.4.3.
Agents and apps
- Three MCP servers. Cypress Operate works in your organization, Cypress Intelligence reads what Cypress knows (valuations, demand, insights), and the public Cypress Manual searches these docs with no key. Streamable HTTP, revision 2026-07-28 and the three before it.
- Thirteen core tools by default. `find`, `get_context`, `brief`, `launch`, `reprice`, `answer_offer`, `advance_closing` and `log` cover the everyday work, and `discover`, `describe_operation` and `run_read` / `run_write` / `run_destructive` reach the other operations (about 400). The tool list an agent reads drops from about 640 KB to 15 KB. `?toolsets=all` shows every operation as its own tool; an area beside `core` adds its own.
- Connect Cypress (OAuth 2.1 with PKCE). An organization's admin approves an app on a consent screen; its token acts for that admin, or for an agent the app adds: a member with the AI agent role, recorded under its own name. An app may also name itself by a client ID metadata document, or register itself (RFC 7591); either one is marked "Not registered with Cypress" and never adds agents. Settings › Connected apps lists them, and disconnecting ends every token at once.
- Some decisions stay with a person, whoever asks. An agent never sets a floor, accepts or counters below it, approves a quarter, confirms that money arrived, emails a contact in your name, sends a campaign or switches an automation back on. It gets `NEEDS_PERSON` with a link to where a person decides, every time.
- An agent sets a listing price under the limits: at or above the floor, never while a live offer or a sale is open, on every door (launch, reprice, the vehicle edit, the bulk price).
- Every write takes a dry run and an idempotency key. A dry run checks and writes nothing; one an operation cannot run on its own checks the caller, the input and the records it names, and says so.
- Tasks and skills. Imports answer as MCP tasks to a client that takes them. The Manual serves three Agent Skills: price and publish, triage offers, chase a closing.
- Sandboxes. A Cypress Manager gives a partner a sandbox from Settings › Developers: eight unlisted cars, two on the market with three offers from a demo dealer, one sold and in its closing, a CRM book and a buy plan, with a test key (`cyp_test_`). Nothing in it reaches a real person or is billed, and it resets itself (`POST /api/v1/sandbox/reset`, `reset_sandbox`) under the same key.
- CM tokens. A Cypress Manager makes a token in Settings that acts as them, for the organization each call names, on the API and on MCP.
The Integrations API 1.5.0
- Every screen's action through the API and MCP: 381 actions, each the screen's own code, with the app's checks, refusal codes and audit. A table pins every screen route to its action, or says why none.
- New areas: offers, the CRM (contacts, companies, deals, notes, interactions, tags, outreach), tasks and Mission Control, insights, notifications and preferences, closings (steps, whose turn, documents, reminders), marketing, your organization's own automations, and Planning for apps and agents (strategy, goals, the quarter, Sequoia's schedule, the buy draft).
- Your own ids on vehicles, listings, contacts, companies, deals and tasks, found with `?externalId=`, and bulk upserts by them.
- Every action heard. A change made anywhere (a person in the app, an agent, an automation, an import) sends its webhook, with `source` and what changed; new event families for tasks, notifications, closings, marketing, the CRM, plans, insights and automations. A 30-day event log with a cursor (`GET /api/v1/events`), replay, and `updatedAfter` on every main list to keep a copy in step.
- Deep links. Every record and event carries `links.app`, a `/go/` link that opens it for whoever follows it.
- The core operations as their own OpenAPI document, in 3.1 and 3.0 (for Salesforce External Services), and `/llms.txt`.
Changed for existing integrations
- A listing price change answers `409 CONFLICT` under a live offer or a sale and `403 BELOW_FLOOR` below the floor; both used to be written.
- A pause through the API withdraws the open offers and tells their buyers, as the app's does. Cancelling a car on the market, or reporting it sold elsewhere, is staged for Cypress's review (`202`) instead of archiving it at once.
- The offer bands a price push sets follow the app's rule: auto-decline below the floor, auto-accept at or above the buy-now.
- An API key acts through an active member only.
- Webhooks from Resend and DocuSeal must be signed: without `RESEND_WEBHOOK_SECRET` and `DOCUSEAL_WEBHOOK_SECRET` they answer 503.
- New fields only on records and events (`links`, `cursor`, `externalIds`); nothing removed or renamed. A subscription without a filter receives none of the new families, and an existing key holds none of the new scopes until an admin adds them.
Automations
- Himo, the automation engine, becomes headless: the same 44 nodes, drawn by one UI kit from a manifest, with a real Wait, and a conformance suite every host runs.
- An organization's own automations (the Automations product, off for everyone until switched on): built, dry-run and submitted through the app or the API, published by Cypress, kept inside the organization.
Fixed (prepared as 3.4.3)
- A buyer no longer receives the seller's signing links, reads the seller's commission in Deal Setup, sees the seller's incidents or paperwork, or reads another seller's campaigns and stock on a contact's timeline.
- An admin renames only its own organization's members, and a route that let any organization's admin consolidate another's invoices is gone.
- The buyer's Bill of Sale link can be re-sent on a v3 closing; an auction rule never auto-accepts below the floor; a dry run no longer uses up a flow's "once per vehicle".
Docs
- Apps & Agents, a section of its own: an MCP quickstart in a sandbox, what an agent can and cannot do, keys and tokens, the core tools, the domain model, events and sync, connecting from Claude, ChatGPT, Cursor, Copilot Studio, Agentforce and Palantir, and an evaluation task set. The quickstart and the evaluations are run against a real sandbox in the test suite.
v3.4.2
2026-10-15Undo means undo, and a live deal is never archived by surprise
Fixed
- An archive rolled back, then archived again, can be rolled back again. A car has one archive record. When a car was archived a second time after a rollback, the record kept the old "Rolled back" stamp, its old date and its old CM decision, so the car sat in Archived while its row said it had been rolled back, and the Roll back archive button was gone. A new archive now starts the record fresh (a new date, the CM decision back to Pending, no stamp), and the button shows whenever the car is archived.
- A rollback puts the car somewhere real. A car archived while Under Offer comes back Listed: the archive withdrew its offers and a rollback revives none. A car whose archive captured Archived or Sold comes back Approved, never Archived.
- Both of a vehicle's feeds tell the story. A Cypress Manager's archive from Seller Hub or the fleet grid now shows on the vehicle's Activity tab with who did it (it showed only on Updates), and a rollback now shows on its Updates tab ("restored from archive — back at Listed"; it showed only on Activity).
- Under Offer means an offer is waiting. When a car's last open offer ends, the car goes back to Listed and leaves Seller Hub's Under Offer tab and its "Awaiting your call" count. Before, only rejecting an offer and a counter-offer expiring did that, so an offer auto-rejected by a price change, withdrawn by a location change or through Cedar, or ended by an archive left the car Under Offer with nothing to answer. The ended offers stay on the car's record, a paused car stays paused, and an accepted deal is not touched. The car stays on the marketplace either way.
- A buyer withdrawing one offer no longer takes the car out of Under Offer while its other offers are still open.
Changed
- The fleet upload asks before archiving a live deal. When an inventory upload would archive a car with open offers or in Post-Sale (a car missing from the file, or one the file marks for archive), review flags it ("2 open offers · best $14,500", "In Post-Sale · Due diligence") and leaves it unticked. Tick it only if the car really left, for example sold elsewhere; Select all leaves it as it is. The banner and the archive group say how many cars have a live deal. Archives from an upload still wait for a Cypress Manager's approval, which shows the car's open offers as before.
v3.4.1
2026-10-14A storefront takes offers on every car it shows
Fixed
- Storefront offers: a vehicle on a seller's storefront but not on the Cypress Marketplace now takes offers. Before, both the no-account offer form and the signed-in offer panel answered "Vehicle is not available for offers" on every such car, though the storefront was showing it with its asking price.
- The offer is billed on the storefront channel, as any storefront offer is.
Kept
- A vehicle that is not on the Cypress Marketplace still answers "not available" to anyone who reaches it through neither one of its share links nor the storefront that carries it. That includes a storefront that does not carry it, so a hidden car is never confirmed to exist.
v3.4.0
2026-10-12Plan what you buy, with Sequoia on both sides
Buying and selling become one planning tool, and the loop between them closes. A dealer plans the quarter's purchases the way a seller plans its sales, on the same calendar. Sequoia reads the market for both sides and records every suggestion with its reasons. Both plans follow one quarter bar, keep Sequoia's rail beside every tab, and carry a Market tab of what the plans, searches, saves and offers on Cypress say is coming. The plan follows each side into the Marketplace, Buyer Hub and Seller Hub, and Cypress staff get a Cypress desk on both pages to broker the fit. Ships together with 3.3.1.
The buy plan
- Buy plan (Planning → Buy plan): the quarter's units goal and budget, a mandate (Restock what sells fast, Keep my usual mix, Most units for the budget, Buy ahead of supply), and targets. A target is a make and model or a vehicle class, how many, by which week, and a target and a maximum price.
- Plan my quarter with Sequoia: a four-step draft built from the dealer's last two quarters of purchases and the market. The steps are the mandate; the goal and budget; 3 to 8 draft targets, each with its reasons, which the dealer keeps, changes or removes, and to which the dealer can add targets of their own, with the same form the Targets tab uses (a How tracks work strip at the top of that step sets the three tracks side by side, and each card has track pills and a High priority box); then markets and notices. Nothing is saved until Create plan. A dealer with no purchase history gets a draft from market signals only, and the wizard says so.
- Target editor: make, model and trim autocomplete with counts on Cypress; dropdowns for body, fuel and condition; two selects for years and mileage presets; one picker for the five regions and their states. Required fields are marked, and errors show under the field, never in a toast.
- Live preview in the target editor: what fits now (exact and near), planned supply as a range and how soon the target should fill.
- Price suggestion from market value: a target 5% under and a maximum 5% over, with −10 / −5 / 0 / +5 steps and the budget impact. Market value comes from Cypress sales of the spec, else market value × what Cypress sales realize, else the median wholesale value of matching listings. With no basis, Sequoia suggests nothing and says so.
- Tracks per target: Aggressive (matches from 80%, offers up to the max, instant alerts), Balanced (from 90%, the default, offers near market value, a daily summary, instant for a high-priority target) and Conservative (exact matches only, offers at the target, a daily summary). Each has its own color, and the track picker says the three lines in plain words.
- Near matches show their score and each miss ("Trim LE, not XLE"). Make, model, class and the target's markets are never relaxed, and a vehicle more than 30% over the mileage limit is no match at any score.
- Matchmaking: every hour Cypress matches open targets against live listings, never the dealer's own vehicles. It groups several vehicles from one seller. Notices follow each target's track: instant alerts, or a daily summary email.
- Price guidance from Cypress's own sales: at least five same-spec sales in the past year from at least two seller companies (a store counts as its company), so a range is never one seller's own prices; else market value × what Cypress sales realize, else "not enough sales", with the asking median labelled as asking.
- Progress that fills itself: a Cypress purchase counts toward the target it fits when its closing starts, and as bought when it completes. Cancellations stop counting. A purchase made elsewhere can be recorded; one from a closing follows its closing.
- Planned supply for dealers: "about 14 midsize SUVs from 3 sellers in the next 6 weeks". These are counts from sellers' plans: never a name, a vehicle or a price, and never fewer than two sellers. The editor's preview shows a range: a few, 5–9, 10–19 or 20+.
- Markets and notices: the regions and states a dealer buys in, a default track for new targets, and how matches are announced: Instant alerts (a Cypress notification and an email within the hour, for an Aggressive target or a Balanced one marked high priority) and a Daily summary email (around 8 AM Eastern, with every other new match), each with a line saying what that means for the dealer's targets. The quarter editor sets the mandate, goal and budget.
- The plan column: steps from Mandate to Review, What needs you, the numbers, and goal cards with the month split.
- Both goal cards are always there. Units and Budget show on every quarter. One that is not set says "No units goal set" or "No budget set" and has a button to set it. The month tiles read compact figures ("$297.5k / $166k"), with the full figures in the tile's title, so they never spill out of the card at 1280 to 1440px. The goal editor picks the mandate with the same cards as Plan my quarter, and No mandate clears it.
- A Buy header on par with the Sell header: a description, then one stats line (open targets · vehicles planned · bought · in closing · "$65,000 of $600,000 spent" · listings fit now) and the "Live · updated 12 min ago" chip.
- One "spent", everywhere. "Spent" is money on vehicles already bought: the header, the Budget card, Review and the Buyer Hub card all read the same figure, and vehicles in closing are counted apart ("N in closing"). The Plan tab's "Spent or in closing" tile is the one place that adds them.
- One quarter, one count. Every number on the Buy plan reads the quarter on the bar: the header's open targets, the Plan tab's numbers, live matches and what is coming to market, the next steps and the plan's own asks, Targets, Progress, and Cedar's counts by track and next steps. The market itself (the rail, the Market tab and "listings fit now") always reads today, and says so on a past quarter. Progress and Targets have an All quarters switch (a heading per quarter, the newest first); a purchase or target saved into another quarter says where it went ("Saved to Q3 2026. Switch to that quarter, or turn on All quarters, to see it.").
- Targets read what became of them. A target with nothing left to buy reads Filled; one whose quarter is over with units missing reads Ended · 3 short. The header and the status chips count the same way, so "open" means open (on a closed quarter the header reads what became of the targets: "3 targets · 1 filled · 2 ended short"), and an ended target's panel shows no live matches.
- A closed quarter is history, on both sides. Once its last day has passed (UTC) nothing in it changes. Its Plan tab shows no live matches, no coming supply and no "What needs you", only "This quarter is closed: see Review for planned vs actual" with an Open Review button. Every action that would change it (add, edit, pause, close or remove a target, the goal, planning the quarter) is drawn off with its reason ("Q3 2026 is closed. Switch to Q4 2026 to add a target."), and the server refuses the write with a 409 `QUARTER_CLOSED`. Recording a purchase made elsewhere, and reassigning a purchase to a target, stay open, because purchases are facts recorded late. Matching, instant alerts and Sequoia leave a closed quarter's targets alone.
- Review reads like Cypress analytics. Planned vs actual shows the goal and the budget as lines with a bar and a standing chip ("Met", "+2 over", "3 short", "$25,000 left", "$12,000 over budget", the last drawn as a warning), and each target the same way. Week by week sets the plan beside what is in closing and bought: a table with this week's own purchases next to the running totals, each week opening to the purchases in it (vehicle, price, target, when the closing started), and a chart that reads out any week on hover. It marks "Quarter starts Oct 1" and "Quarter ends Dec 31": the Buy chart shades the quarter, and the Sell scorecard has marker rows (a window that straddles an edge says so).
- Approve the quarter, like the sell plan. The buy plan stays live, and approving a quarter keeps a baseline: the goal, the budget, the mandate and the quarter's targets as they stood, with the dealer's own fields only and no match, purchase or seller. The quarter bar reads "Approved Oct 3 · 4 changes since" (the same line as the sell plan, from one shared formatter), with Approve quarter and Re-approve on the bar (Review offers Approve quarter too, until the quarter has a baseline). The approval is bound to what is on screen, a plan that moved since is refused ("The plan changed. Refresh and approve again."), and a quarter that is over, or has no goal, budget or target, cannot be approved. Review gains Planned vs actual (units, budget and each target against the baseline; a removed target says so) and Changes since approval in plain words ("RAV4 XLE: quantity 4 → 6", "Added Chevy Malibu", "Budget $600,000 → $650,000"). Plan my quarter with Sequoia gets an approve box on its last step, ticked when the quarter has no baseline. Both plans now read as live, with no version numbers.
Sequoia on both sides
- One Planning layout for Sell and Buy: the plan on the left, Sequoia's live market rail on the right of every tab and every quarter (above the plan on narrow screens). The rail filters by All, Opportunities, Supply (Buy) or Demand (Sell) and Trends. The Sell / Buy switch sits beside the page title, and a tab shows a focus ring only for the keyboard. Staff get the Cypress desk as the last tab, in a teal staff look.
- One quarter bar above the tabs on both sides, the same on every tab. It shows the quarter's name and its dates ("Q4 2026 · Oct 1 – Dec 31, 2026"), the arrows, the mandate, the approval line ("Approved Oct 3 · 4 changes since") and the quarter's actions, the primary one last on the right, so nothing jumps when you change tab. The Sell plan's Plan, Schedule, Decisions and Review tabs and the Buy plan's Plan, Targets, Progress, Decisions and Review tabs follow the quarter, so their arrows move it; Decisions, Targets and Progress have an All quarters switch (the bar then reads "Q4 2026 · showing all quarters"). Vehicles and Market show today and say so in a line at the top of the tab ("This tab shows today's fleet", "Today's market"), not in the bar. A quarter that is over wears a slate Closed badge with a lock beside its dates and a Go to Q4 2026 button; the rail then says "Showing today's market — you're viewing Q3 2026." and, since a past quarter has no forecast to read, draws no Next weeks block.
- The rail folds. A folded rail leaves a handle that reads "Sequoia · 3 suggestions", with a count badge that turns amber when something needs you and a dot when something is new. The choice is remembered per side in each browser, the rail folds by itself while Cedar is docked, to make room, and a keyboard user gets a "Skip the market rail" link. A rail folded from the start reads its forecast the first time it is opened.
- Every recommendation says what it is, why (expanding to the numbers behind it), how confident Sequoia is and what to do, on the rail and in What needs you alike. Its actions are the primary action (which is recorded, on the right), Ask Cedar and a Later menu: "Remind me next week" and "Not this quarter".
- Cedar knows which card you asked about. Ask Cedar sends the card with the question, and Cedar can read its numbers and your own lot or target (`get-sequoia-item`), so it no longer starts by asking for the mileage, condition or ZIP of a vehicle you own. The card rides on the first message only.
- What needs you shows at most two Sequoia cards above the plan's own asks, which are never pushed out.
- A Decisions tab on both sides, in Sequoia's own look (the sparkle, its amber accent, "Sequoia · Decisions"). What waits on the person comes first, in a Needs you group, then the trail by day, each row badged Suggested, Needs you, Done, Skipped or You decided, with a confidence chip, its reasons and the guardrail that made Sequoia ask. The guardrails sit in one folded row ("Guardrails · Sequoia suggests; you decide") that opens to what Sequoia may do on its own and what it always asks first; open or folded is remembered per person and per side. An item hidden with Not this quarter is not suggested again in the same quarter; one put off with Remind me next week comes back after a week. Decisions follows the quarter on the bar, or lists every quarter with All quarters ("No decisions in Q3 2026. Switch on All quarters to see others."). On a closed quarter it is the record only: "What Sequoia suggested for Q3 2026, and what became of it.", with nothing left to decide.
- Suggest mode: Sequoia proposes and never acts. Any offer, any price above target, spending over 10% of the quarter's budget, and any change to goals, the mandate or the sell schedule is marked Needs you, with the rule that applied. It may, without asking, watch a vehicle, notify you, and draft a target, an offer amount or a lot move.
- Sequoia reads the market every hour and stores what it finds. A change to a target, a goal or the markets asks for a fresh read about 30 seconds later, and a brand-new plan says "Sequoia is reading the market" until the first run.
- The Sell plan's header carries the same two Sequoia buttons as the Buy plan, after Import fleet and Costs: Ask Sequoia and Plan my quarter with Sequoia (renamed from Mandate & goals; it opens the quarter plan: mandate, goals and constraints).
- Next weeks: a forecast in the market rail. Dealers see what sellers plan to bring to market for their targets (Next 2 weeks, Next 6 weeks, This quarter); sellers see the demand ahead for their lots, as ranges of vehicles wanted, and how much other sellers plan in their windows (Light, Normal or Heavy). Ranges and levels only, from at least two companies, never names, prices or VINs.
- Seller Planning keeps its plan logic. It gains the rail, demand cards in What needs you ("4 dealers plan to buy 11 like your W47 lot this month: pull forward?"), the Decisions tab, and `?lot=` and `?window=` links that open a lot or a sale window.
- No plan version numbers. Both plans read as one live plan: the sell chip says "Live · updated 12 min ago", and each quarter "Approved Oct 3 · 4 changes since". The free-text `reasoning` of Sequoia plan advice, which the partner API also returns, no longer carries a plan version number ("Sequoia's live plan: …", not "Sequoia plan v12: …"); the API's shape is unchanged, and advice stored earlier keeps its old text until the plan writes it again, within 30 days.
The Market board, on both sides
- A Market tab between Decisions and Review, on the Buy plan and the Sell plan. It reads the plans, searches, saves and offers companies keep on Cypress, by make and model, and what is selling fastest, and shows today's market ("Today's market").
- What this means for your plan comes first: up to four of Sequoia's own market-driven items, as the rail's cards with their own actions (on the Buy plan hot specs, supply coming and price trends; on the Sell plan demand now, hot demand, competing supply and a trend that says sell sooner). Board rows tie back to the plan with a chip ("You hold 8" on the Sell plan, "Your target: RAV4 XLE" on the Buy plan) and one Sequoia line with its action when Sequoia has an item about that model. With none: "Nothing in the market calls for a change to your plan right now."
- Hot on Cypress · selling fastest now lists up to eight models, from the Cypress sales Sequoia's rail already reads (at least five sales behind each model), with a sentence like "Sells in about 12 days on Cypress". Never a seller, a price or a count of sales; models the viewer holds stay in.
- What dealers want · plans, saves and offers on Cypress lists the models companies outside the viewer's own want, alphabetically, each with the chip Wanted on Cypress. Four signals feed it: open targets in plans, saved searches that name one make and its models, vehicles saved in the last 30 days and offers made in the last 30 days. It shows which models, never how many, and never which signal put a model there: no quantity, no range, no count of companies. A model the viewer's own company holds has no row there; the Planning page already shows that demand on your lots.
- What sellers plan to bring · this quarter lists the models other sellers plan to bring to market before the quarter ends. With at least three sellers behind a model the row shows a range ("under 10 vehicles", "10–19 vehicles" or "20+ vehicles"); with fewer it shows a quiet Planned chip and no number. Ranged rows come first, then the Planned ones alphabetically.
- A row needs one company, and never says how many. The board starts with any number of companies outside the viewer's own tree, so it is never empty for want of a threshold; a row is one fact, "someone else is on this model", and no field carries a count of companies. Both columns list every qualifying model: the first 12 show, with Show N more.
- Live listings and the trend sit beside each row ("7 live", and the direction of Cypress's market value for the model, in words with no price and no vehicle count), with actions: Add a target like this (opens the target editor with the make and model filled in) and See live listings on the Buy plan, Open my matching vehicles and See live listings on the Sell plan.
- Being on Cypress is what feeds it. There is nothing to post and nothing to switch on, and the tab says so: "Part of being on Cypress: the plans, searches, saves and offers of companies here feed this board, anonymously — never names, prices or VINs." Offers and saves on vehicles that are not shown on the Marketplace (private links, storefronts) never count, a person never counts as a company, and only active buying companies do. Free text never reaches the board: a make and model must pass a clean-name gate and be one Cypress already lists or sells. Demo organizations see a demo-only market, and every state ends with "From plans on Cypress. Plans change; nothing here is a commitment."
- Cedar reads it: `get-market-board`, with the same models, ranges and trend, and the same warning never to say how many.
Buy analysis
- "Buy this one now, or wait?" For a listing that fits one of the dealer's open targets, the Marketplace vehicle page gains a Buy analysis card, and the target panel's match rows show the same four blocks when a row is opened: Market position (the ask, the market value, what Cypress expects, your target and maximum, and what Sequoia would offer), Price to your need-by (the market value along the Cypress trend to the week you need it, and what waiting likely does), Buy now or wait (the other fits, what sellers plan before you need it, how long it has been listed) and Fit and budget (the match and what it misses, what you usually pay for the make, and the quarter's budget after this ask).
- Sequoia never offers above the asking price. When a target's offer would be higher than a listing's ask, Sequoia says "Buy at the ask: $23,650 is under what Sequoia would pay", wherever it names an offer for a listing (the vehicle page, the target panel, the analysis). It never sends an offer on its own.
- Made from public and own data only: the listing's public ask, Cypress market values and sales, the dealer's own plan and purchases, and the forecast's ranges. Never the seller's floor, limits or name.
The Sell plan's schedule
- The schedule reads at a glance. Every lot carries a thin status bar and one line under its name ("3 sold · 2 in market · 1 handed · 1 scheduled · 2 to plan": the non-zero states, where the money is first, with In operation and Candidates counted as to plan). A lot with every vehicle sold reads All sold, in a lighter fill, and moves below an "All sold" divider.
- One palette, one meaning per colour. Scheduled is the plan's primary, handed to Sell is sky, in market is amber, sold is solid emerald with a check (no longer black), missed is red with a warning mark, and planned demand has a hue of its own, violet. The cells, the legend, the bars, the chips and the stage chips (SOLD emerald with a check; LISTED and OFFERS amber) all read the one table, and every pair reaches WCAG AA. The Planned demand callout is violet everywhere it shows: the schedule, the lot panel, Seller Hub, Leads and the Fleet panel.
- One window header. Every window lays out the same five rows: its label and state, its dates, "hand to Sell by Oct 14" (and "— past due" in amber when the date has passed with vehicles waiting), its counts as chips, and two buttons that line up across the columns, Analyze and Hand to Sell. Row and window actions are always drawn, not only on hover; one that cannot apply is off and says why ("Nothing scheduled in this window yet"), and Hand to Sell reads Handed when nothing is left to hand over. The grid opens with the NOW window in view, and only scrolls when NOW would be cut off.
- The window panel opens on "Market for this lot": the planned dealer demand the grid cell's tooltip already says, "Wanted by dealers, next 13 weeks" and "Other sellers' planned supply through W43–44" (Light, Normal or Heavy), each only when it is available and with the forecast's fixed line under it.
- Taking vehicles out of the plan is one menu that asks first. "Take out of this window" in the panel, "Change stage" on the Vehicles tab: Back to the bench (still for sale this quarter; Sequoia can schedule it again), Park until… (not this quarter; it returns to the bench on the first day of the quarter you pick, from the next four quarters or Indefinitely) and Back to operation (it leaves the sale plan and keeps working in the fleet). The confirmation says what will happen in numbers and dates, counts only the vehicles that really move, and says how many stay as they are.
- Moves to a window and track changes can be undone from the toast. Undo reads the plan again first and leaves alone any vehicle that changed since ("3 changed since and were left as they are.").
- Review scores only the quarter's own windows. A window counts in the quarter that holds its 8th day, so Q3's Review no longer lists W41 to W52. Window reviews open and close with a chevron and "Show details" / "Hide details", and a closed window's name on the scorecard opens and scrolls to its review.
- A closed quarter is history on the Sell plan too. Moving vehicles into its windows, benching, scheduling, taking out, parking, changing a track, handing a window to Sell, editing the goals and resetting the plan are drawn off with their reason, and the server refuses them with 409 `QUARTER_CLOSED` (vehicles that slipped, whose window closed unsold, can still leave, and an unsold one can still be carried to an open quarter's window). Analyze, the vehicle lists and Review stay: reading is what a closed quarter is for.
Planning everywhere, when it is switched on
- Marketplace: a "Your Q4 plan" strip (bought against the goal, and how many listings fit any open target) with Show only these (the best 150 when more fit), and a match badge on every listing that fits a target ("92% · Midsize SUVs"), in the grid, list and gallery. With Buy planning off, a quiet Plan your buying link.
- Marketplace vehicle page: "Fits your target Midsize SUVs (92%) · 4 still needed by W48", with the target's price guide and what Sequoia would offer on the target's track ("Sequoia would offer about $24,800", or "Buy at the ask" when that is more than the listing asks), and the Buy analysis card. Market-based only; Sequoia never sends the offer.
- Buyer Hub: a plan card with the units and budget meters ("3 of 20 bought · 1 in closing", "$65,000 of $600,000 spent · $26,000 in closing"), the targets due soon and Sequoia's top card. Deals still show the target they count toward.
- Seller Hub: a plan card with the goal meter, the current sale window, the vehicles that missed a window and Sequoia's demand card, plus a Sell plan link for every seller.
- Sellers see anonymous planned demand ("3 dealers plan to buy 9 like this in the next 4 weeks") in Seller Hub, the fleet panel, Leads, lots and the schedule. It is bound to their own lots and windows: never a name or a price, never fewer than two dealer companies (a dealer with several stores counts once). Every seller still sees the demand count for their own vehicles, as before, whether or not Planning is on.
- The Cypress desk (staff only) is the staff tab on both Planning pages, last in the bar, in a teal staff look with a "Cypress only" badge. It replaces the staff Board, and sellers and dealers never see it. The Buy desk lists every dealer's open targets with what fits on Cypress today and a win-win fit across both sides' limits: a row's dealer and target open the dealer's plan on that target, under that dealer's view; its live matches open the Marketplace on the target, and its best-fit chip opens the vehicle. When Offer for dealer is off, the row says why under the button ("Not delegated: the dealer hasn't let Cypress make offers.", with a Delegation settings link, or "Still onboarding: Cypress can offer once the account is active."). The Sell desk is its twin: every seller's lots in the quarter's coming windows, with the dealers whose open targets want them, and three actions, Open seller's plan, Adjust pricing (acting for the seller, off with its reason when the seller has not let Cypress act for them or nothing is in Sell yet) and Matching dealers (the Buy desk on those targets). Both desks follow the quarter bar, keep their filters in the address so a view can be shared (fit, when it is needed or which windows, Delegated only, a name search), and, when staff act for an organization, show that organization alone; with none chosen they list every organization, demo and real, each lot meeting only dealers of its own world. Matching dealers opens the Buy desk with no organization chosen, on every target that wants the lot.
- Staff only, by construction: the desk routes answer 403 to anyone who is not Cypress platform staff, and an automated check fails the build if any file outside the staff modules reads a seller's floor. Each dealer's account manager also gets a daily line of new matches.
- Privacy: a dealer never receives a seller's floor, limits, name or id, and a seller never receives a dealer's name or price. The deal fit reaches Cypress staff only, and an automated check now covers everything Sequoia and the new Planning surfaces return, the forecast, the Buy analysis and the Market board included.
- Dealer demand is counted per company, everywhere. A dealer with several stores counts once on the seller's per-lot demand, the schedule tooltip, Seller Hub, Leads, Sequoia, the forecast and the Market board, so a few stores of one company never pass as several dealers. The forecast shows a range only when at least two companies stand behind it; the Market board needs one company outside the viewer's own for a row, never says how many, and shows a supply range only with at least three sellers.
- The forecast and the Market board were attacked, then closed. Each went through repeated adversarial review passes, and every way found to isolate one company's plan (subtracting one range from another, reading the board before holding a model, a dropped remainder) was closed and is now an automated check that walks thousands of possible plans. That is why "What dealers want" shows presence and never a quantity.
- Cedar: `get-buy-plan-brief` adds the mandate, tracks, near matches, top recommendations, recent decisions, and whether the quarter is approved and what has changed since; it and `get-sell-plan-market` carry the Next weeks forecast. `get-sell-plan-market` reads the sell rail, `get-sequoia-item` reads the card you asked about, `get-market-board` reads the Market board, and there are new chips on the Buy plan and on Planning.
Automations
- A flow never applies or removes Planning's `plan-` track label, a label the platform keeps, or another organization's label. The label picker does not offer them to an Apply Label or Remove Label step, publish refuses a flow that names one, and the worker refuses the step if one slips through (the step fails, the run closes Partial and the flow's author is told). Cedar's flow builder is neither shown one nor allowed to propose one. A Has Label condition can still read a track.
- Plan events are opt-in for flows. This changes existing any-field flows. A Record Updated flow now runs on a plan event (benched as a candidate, scheduled into a window, hand-to-Sell date arrived, missed its window, blocker added, blocker resolved, handed to Sell) only when its watch list names that event, as the `PLAN.*` webhooks already do. A flow with an empty watch list ("any field") still fires on any field change, but it no longer runs on a plan event. If one was built to react to a hand-off, a blocker or a missed window, name the event in its watch list. The flow editor says so where the watch list is picked.
- Demo organizations are a separate world for flows. A global flow never runs for a demo organization (not on its events, not in the time-elapsed sweep) and never notifies Cypress managers about a demo vehicle. A demo organization's own flow still runs, so a demo can show automations.
- An honest dry run and publish. On planning stock, a dry run now reports the steps the real run refuses (change status, listing price, floor price, pricing rule, valuation) as blocked, with the same reason. Publish reads the flow's Works in, widened by any plan event it watches, and refuses those steps, and an offer sheet emailed outside Cypress, on a flow that works in Operation or Planning. Send Email refuses that offer sheet in a real run and a dry run too.
- "Due diligence started" is no longer offered as a trigger. Nothing ever fired it: no code creates a due diligence record, and a vehicle reaches Due Diligence through several routes, so a flow built on it never ran. To react, watch Status on a Record Updated flow and branch on Due Diligence. A saved flow that has it still opens, and says so.
- Words and the hub. Two plan events read as the developer docs read them, "Benched as a candidate" and "Hand-to-Sell date arrived" (the keys are unchanged). The Automation Hub's Trigger column shows labels, not raw keys such as `plan.scheduled`.
- Sales channels in flows. A new Set sales channels action says where a vehicle is sold: Cypress Marketplace, Storefront and Direct Links, at least one, by the Integrations API's own rule (the vehicle is reachable exactly there). It never lists, prices or launches a vehicle, a channel the company holds no grant for fails the step in the API's words, and planning stock is refused like the other listing-side steps. Flows can also tell offers and vehicles apart by channel: an optional Came through list on *Offer Received* and *Offer Accepted*, an Offer came through condition, an `{{offer.channel}}` variable for notifications and emails (Public, Promotion, Direct or Storefront, never the buyer or a link), and a Vehicle reachable on condition that reads the Seller Hub's channel chips.
- "Offer accepted" runs for every accepted deal. This changes existing flows built on it. The trigger used to run only when a seller accepted an offer by hand. It now also runs when the vehicle's auction rules accept an offer as it arrives, for a Buy Now purchase, when a buyer accepts the seller's counter, and when a price change a Cypress manager approved accepts an offer that now qualifies. It runs once per offer, however many places report it, and carries the amount, the channel the offer came through (so a Came through list sees every one of them) and, for a Buy Now, that it was one. It never carries the buyer. A flow that was built to react to accepted offers now reacts to all of them; one written on the assumption that only hand-accepted offers reach it will see more runs.
- Cedar: the `/planning` description names the Sell plan and the Buy plan, Sequoia's rail, Decisions, the Market tab and the Cypress desk. The flow builder takes an optional Works in, and repairs a Planning flow that sets a price before it offers it. Cedar's automation instructions carry the Planning words.
Newsroom
- A public Newsroom at `/newsroom`, with two shelves: press releases, open to anyone, and architecture discussions, behind a sign-in. Both are the repository's own markdown (`docs/releases`, `docs/architecture`), so there is no second copy to keep in step.
- Publishing is opt-in. A file is a post only when its front matter says `published: true`; `pnpm newsroom:generate` bakes the published posts into the build, and a test fails when the build is behind the markdown. A press release loses its Operational section on the way out.
- Who reads what: `public` (anyone), `account` (a member of an organization Cypress has activated; a consumer account or an organization still registering does not pass) and `staff`. An architecture discussion can never be public. A signed-out reader receives nothing of a gated post and cannot tell a gated address from one that does not exist; a sign-in started from a gated page returns to it.
- Only this release is published. Which earlier releases and which architecture notes go up is still to be decided.
Fixed
- Sell schedule: sold was drawn black; a lot's progress could not be read without scrolling through the weeks; row actions showed only on hover; the window buttons wrapped and sat out of line; and the grid could open with the NOW window cut off. See "The Sell plan's schedule".
- Review (Sell plan) listed windows outside the quarter and gave no sign that a window opens its review.
- Sold vehicle's panel (Sell plan): the header showed the best window of the hold analysis instead of the window the car sold in. It now shows a "Sold in" tile with that window and no hold advice ("Sold, so there is nothing left to hold."; "Left the fleet, so there is nothing left to hold." for a vehicle that left); the trend chart's labels no longer overlap, and a one-week window reads "W41", not "W41-41".
- Marketplace vehicle page: "Instant purchase available" shows once, and Make an Offer is never a white button.
- Sell plan, closed quarter: a closed quarter no longer shows Sequoia's live demand cards, and Reset to the base plan is off there.
- Makes read as names in the Buy analysis, the target panel and the Market board: Kia, Jeep Wrangler, not KIA, JEEP WRANGLER (BMW, GMC and BYD stay as they are).
- Cedar: a call to a name that is not a tool is no longer drawn as "Tool error: item", real tool failures are still shown, and Cedar is told to keep its working memory to itself and to say its answer once, instead of reading its own notes aloud. A Sequoia demand card names its lot, so Ask Cedar can say which vehicles fit.
Security (found by the final review)
- Staff-only tools check for Cypress staff on every request. A group of internal tools (worker jobs, notification testing, processing logs and the paid re-valuation of a vehicle) relied on a check that was not strict enough about who is platform staff. Each now refuses anyone but Cypress staff before it reads anything, and a re-valuation is limited to the vehicle's own organization. A guard test fails the build when a staff route carries no such check. The fix does not depend on the rest of this release and goes to production on its own.
Fixed by the final review
- A price a dealer reads needs two seller companies at every tier. The price guide's first tier already refused five sales of one company; the second tier (market value × what the model's sales realized) then gave that company's own range back. The realization read, the *Similar vehicles sold at N% of ask* line on a match and the prices Sequoia drafts now ask for the same floor and fall back to the class. Hot on Cypress keeps five sales for its pace; only its "sold at N% of asking price" figure is left out for a one-company model.
- Plan my quarter refuses a kept target whose "Needed by" date is in a quarter that is over (`409 QUARTER_CLOSED`, and the wizard says it on the card). Before, the target landed in last quarter and could never be edited or removed.
- A price change that accepts an offer starts its closing. The approval set the offer accepted and stopped, so the deal was in nobody's Post-Sale and the dealer's Buy plan never counted it. It now closes the vehicle's other open offers and starts the closing, as every other accept does, and an approval accepts one offer at most (`422 ONE_ACCEPT`).
- A failure after an auto-accept has committed (Buy Now, or the auction rule) no longer skips the closing or answers as if nothing was accepted.
- Webhooks: an event with an id is no longer stranded when the worker is cut off between storing the delivery and queueing it, and two deliveries of one id at the same moment (a double click on Accept) send it once. `BUY.TARGET.MATCHED` is told as soon as the matches are stored, and the next hourly run repeats it under the same id, so a run that failed loses nothing and an endpoint still receives each id once. `BUY.TARGET.ENDED` is sent even when a digest of the same daily run fails.
- Hiding a match works only for a match of the target (`404` otherwise), so the answer no longer says whether a vehicle id exists.
- "What dealers want" no longer counts the vehicles a saved search saves on its own ("Auto-save matches") as saves a person made; one broad search could put most of the Marketplace in the column.
- The delivery log (`GET /api/v1/deliveries/{id}`) shows a `BUY.*` body only to a key with the Buy planning read scope; another key sees the delivery with `envelope.body: null` and `bodyWithheld`.
- Automations: a label owned by the Cypress organization itself is applied to any organization's vehicle again (the new label guard refused it as another organization's); a dry run says when the real run would refuse a label; a Cypress-wide flow run by hand on a demo vehicle leaves a line on the vehicle saying why it did not run.
- Scheduling a month that is no month (`2026-13`) answers `422`, not `500`.
- The Cypress desks fit a laptop. Beside the market rail a row's three actions and the line that says why one is off were off screen at 1280 to 1440 pixels. They now stay in view at the right edge of the card, the staff tab's "Cypress only" badge is never cut, each desk says whose it is while it loads or is empty, and a demo company wears a Demo badge.
- Approve on a closed quarter reads what is true: Approved on a quarter that was approved (it said "Approve plan" beside "Approved Jul 2"), with the closed quarter's own reason on both bars. A closed quarter that was never approved says so on Review.
- A Sequoia suggestion on a closed quarter is no longer recorded as accepted when its action can do nothing there; the suggestion stays.
- "Short" means one thing. Review counted a vehicle in closing as short while the Targets tab did not, so one target read "2 short" and "1 short". Both now count the vehicles with no purchase and no closing, and Review says Rest in closing when that is all that is missing.
- Sell plan, at a quarter's start: the window that straddles the quarter is closed with the quarter it counts in, and the screens now say so on that window ("Q1 2027 is closed, and this window counts in it") instead of offering actions the server refuses. A refused bulk change names the quarter and how many of the chosen vehicles sit in it, and no Undo is offered into such a window.
- Review opens the window you asked for once, not again after every saved verdict.
- Keyboard and screen readers: every button shows a focus ring when reached by keyboard, the active tab is announced, and focus no longer falls to the top of the page when the last card of the Market tab's strip leaves.
- Smaller things: the Decisions badge is not shown over a closed quarter's record; "Add a target like this" is drawn off with its reason on a closed quarter; an endpoint with no event filter says what it really receives (every event except the Planning and Buy planning ones); a status chip no longer breaks over two lines; counts in the schedule read "1,250"; the Planning page's Retry is no longer a white button; and the Cypress desk tab no longer pushes the tab bar down when chosen.
Docs
- The Planning manual comes in three parts: Sell planning for fleets, Buy planning for dealers and Planning together for what joins them. Each part has its own overview, sidebar entry and icon, and every old Planning address forwards to its new page in one step.
- Buy planning at the depth of Sell: six step-by-step guides (Plan your quarter with Sequoia, Set the quarter's goal and budget, Add and edit a target, Read matches and the Buy analysis, Record a purchase made elsewhere, Approve and review the quarter), The Buy plan, tab by tab, and the Buy process drawn end to end, from the goal and budget to Review.
- Planning together: the closed loop drawn on one calendar, Sequoia's market rail and decisions, the Market board, The quarter bar and closed quarters (new), Planning inside remarketing (every place the plans show up outside Planning: Seller Hub, the Fleet panel, Leads, the Marketplace, Buyer Hub and notifications) and The Cypress desk (new), the staff chapter: both desks, the columns, the fit tiers by name and meaning, every action and why it can be off, delegation, filters, and a worked example.
- Automation flows: a new "Flows and Planning" section (Works in, the seven plan events and when each fires, why they are opt-in, what a Planning flow cannot do, the `plan-` labels, and a recipe: window missed, then notify the fleet manager). The build-an-automation guide and Planning inside remarketing point to it, and "due diligence starting" is no longer listed as a trigger. A second new section, "Sales channels in flows", covers Set sales channels, the channel of an offer and Vehicle reachable on, with a recipe: a Storefront offer, then notify the fleet manager with the channel, and a third, "The offer triggers", says which deals Offer accepted runs for. "List to the marketplace and channels" points to the action.
- Keys, scopes and the API, from the manuals: the Platform manual says how an admin makes an API key and what an endpoint's event filter offers, with the planning scopes by side (Sell planning, Buy planning), each offered only while its product is on; switching Planning or Buy planning on says what it opens for the organization's keys and endpoints; Sell planning and Buy planning each end with a short "Connect your ... system" section. Sales channels & commissions gains "Channels in automations", and Offers & negotiation "Offers in automations".
- Developer docs held to the contract: the Webhooks catalog spells every event in full, a new "Offer events" section says what `OFFERS.ACCEPTED` covers, the "Changes in 1.4.0" list of the API reference is one list in one order, and a test fails the build when an error code, an event or a key scope in the API contract has no place on its page (or a page documents one the contract does not have).
- Five new diagrams: the Buy process, a target's life (open, matched, committed, filled or ended), the Sell process, the closed loop and the Cypress desk.
- Every guide is step by step: one action per step, and what you should see after it, on Sell and Buy alike.
- Five worked examples with fictional companies: a selling quarter, a buying quarter, a closed quarter's history, the closed loop on both sides, and the Cypress desk.
- 101 screenshots, each at the end of its step: customer screens captured signed in as the example company, the Cypress desk as staff. Ten Sell guide images from before the quarter bar are recaptured.
Buy planning over the API (Integrations API 1.4.0)
- Fourteen endpoints under `/api/v1/buy-planning`, all on the key's own organization: the quarter (`GET` and `PUT /quarters/{periodKey}`, with `current` for the quarter in progress; its goal, budget and mandate, its approval, its progress, and the `digest` that `POST .../approve` binds to), the targets (list by quarter and by display status, add, read, edit with `{ status }` or the whole body, remove), a target's live matches and hiding one, and the purchases (list, record one made elsewhere, move to a target or to none, remove one recorded by hand). The app and the API call the same code, so they cannot disagree: a target needed in a quarter that is over, and the goal of one, answer `409 QUARTER_CLOSED`; a purchase is never refused for a closed quarter; an approval keeps its own `PERIOD_CLOSED`, `NOTHING_TO_APPROVE` and `PLAN_CHANGED`. Recording a purchase has no idempotency key, so the docs say read first, then record.
- Matches carry a listing's public facts only: vehicle id, a public Marketplace URL, year, make, model, trim, mileage, state and the public ask, with the score and what a near match misses. Never a seller's name, id, floor, thresholds or limits, no VIN, and never the staff deal fit. A key is a dealer; it has no staff door. The privacy guard now runs through the key door, over listings, purchases and rows laced with sellers' names, ids, limits and VINs.
- New scopes `buy-planning:read` and `buy-planning:write` (write implies read), never in a key's defaults, and a new refusal `403 BUY_PLANNING_NOT_ENABLED` when the organization's Buy planning is off. Buy planning is a switch of its own, separate from Planning, in either direction. Also `409 HAS_PURCHASES` (a target with purchases is closed, not removed) and `409 FROM_CLOSING` (a purchase from a Cypress closing follows its closing).
- Discovery: `GET /api/v1/organization` gains `products.buyPlanning { enabled, requestedAt }` and the capabilities `buy-planning.read` and `buy-planning.write`, from the same rule the routes refuse by. The Integrations page offers the Buy scopes only when Buy planning is on.
- The scope labels read by side: "Sell planning (read)", "Sell planning (read and write)", "Buy planning (read)" and "Buy planning (read and write)". The scope strings are unchanged, so existing keys are too.
- Three webhooks tell the dealer's systems when the plan moves (also Integrations API 1.4.0, so still no new version number): `BUY.TARGET.MATCHED` when a live listing fits an open target for the first time (once per target and listing, from the hourly match run, whatever the target's track and notice setting, never for a quarter that is over or across the demo and real worlds), `BUY.TARGET.FILLED` when a target has nothing left to buy (on the transition only: a closing that fits it starts, a purchase is recorded or moved to it, or its quantity is lowered to what is bought), and `BUY.TARGET.ENDED` when a target's quarter closed with units still short (from the daily Buy cron, over a two-day window, so a missed day loses nothing). They go to the dealer's own endpoints on the same terms as `PLAN.*`: opt-in (an endpoint with no event filter never gets them), Cypress names only (a Manheim-names endpoint that lists one is refused with `422`), subscribing needs `buy-planning:read` (`403 SCOPE_MISSING`, or `403 BUY_PLANNING_NOT_ENABLED` when Buy planning is off), and nothing is sent while the organization's Buy planning is off. The Integrations page offers them under the same conditions. Each carries the same target and match objects the reads return (the match is a listing's public facts, never a seller, a floor, a limit or a VIN), built when the event happened, and each is delivered to an endpoint once however many times its cause is noticed: a delivery now remembers the event it came from, in a column that already exists. The privacy guard runs the real match run, the transitions and the daily sweep over a world laced with sellers.
- Developer docs and kit: a new page, Planning API (Buy planning), with a six-step quick guide, the reference, the closed-quarter rule, the webhooks and what stays in the app (Sequoia, the forecast, the Market board, Plan my quarter, and the Buy at the ask offers); the OpenAPI document (a Buy planning tag, every operation and every error it can answer, and the three events with their bodies); the Webhooks page; and a Bruno and Postman folder, 6-buy-planning, which ends by subscribing to the three events.
The closed quarter over the API (Integrations API 1.4.0)
- New code `QUARTER_CLOSED` (409). A quarter whose last day has passed (UTC) is history, and a window counts in the quarter that holds its 8th day. The endpoints that change a plan refuse it: moves (into a window, or to `OPERATION` from one), track (planned vehicles only), park, and the hand-off preview and confirm. Nothing is written and it is not retryable. `unpark` and a move to `BENCH` cannot meet it through the API: a parked vehicle has no window, and a scheduled one is skipped as "already scheduled".
- The refusal names the vehicles. A bulk request that would change a vehicle in a closed quarter's window is refused whole, and the body gains `details.vehicleIds` (exactly the vehicles it blocks, so a sync job can send the rest again) and `details.quarter` (`2026-Q3`) when they share one. `error` and `code` are as for every refusal.
- Which windows are closed. Every window of `GET /api/v1/planning/plan`, and the `window` of every plan vehicle, carries `quarter` (`2026-Q4`) and `closed`, the test the server refuses by. A window of a closed quarter that has not been swept yet still reads `PLANNED` or `ACTIVE`, so `state` alone no longer tells.
- A vehicle in Sell keeps its track, in the app and over the API: a car still selling has a pricing posture that must not freeze, so only planned vehicles are frozen with their quarter. Blockers and pulling a handed-off vehicle back to planning stay open on a closed quarter too (they are facts, or the vehicle's future).
- The preview agrees with the confirm. A hand-off preview of a window of a closed quarter now answers `409 QUARTER_CLOSED`, as the confirm does, instead of a digest nobody can confirm.
- Plain words. The stored plan advice `reasoning` and `plan.note` no longer carry a plan version number ("Sequoia plan v12: …" reads "Sequoia's live plan: …"), as everywhere in the app; they are free text for people, with the shape unchanged. `planVersion` is documented as a revision counter, `to: BENCH` and `to: OPERATION` list what they skip, and "Planning" in the Sell endpoints is named Sell planning (Buy planning has its own, above).
- Developer docs and kit: the Errors page, a Closed quarters section in the Planning API, the OpenAPI document (1.4.0), and the Bruno and Postman collection (03 reads `closed`; 05, 08 and 09 name the refusal).
Changed for existing keys (Integrations API 1.4.0)
- A request that changes a quarter that is over now answers `409 QUARTER_CLOSED`: a move into a window of such a quarter, a move to `OPERATION` or a park of a vehicle scheduled in one, a track change of a planned vehicle in one, and the hand-off of one of its windows. Before, these were accepted, except that a move into a window, or a hand-off, answered `409 WINDOW_CLOSED` when the window itself had closed.
- `WINDOW_CLOSED` is answered after `QUARTER_CLOSED`, so only for a window that has closed in a quarter still in progress.
- Sequoia's `reasoning` and the plan's `note` read without a version number (see above); nothing else about them changes.
- `OFFERS.ACCEPTED` is now sent for every accepted offer. The manual accept, an auction-rule accept and a Buy Now already sent it. A buyer accepting the seller's counter, and a price change that accepts an offer that now qualifies, accepted the offer and sent nothing; they now send it. An endpoint that lists the event, or lists none, receives those from the deploy on. No field was added to any event, and an accepted offer is announced to an endpoint once.
- The scope labels on the Integrations page read by side ("Sell planning (read)", and so on). The scope strings, and what each scope grants, are unchanged; Buy planning is new scopes, not a change to the old ones.
Operational
- Off by default. Buy planning is off for every organization, and Planning (sell) stays as each organization has it. Cypress switches each on from the organization's page, under Products. With it off, the Marketplace and each hub show only a plan link.
- Five migrations, applied by hand before the push, in order: `20261006120000_buy_planning_core`, `20261006130000_buy_planning_matches`, `20261006140000_buy_planning_fills`, `20261007090000_planning_v2`, `20261008090000_buy_period_baseline`. All five are additive, and no migration has been added since the last one; the fifth is three nullable columns on `BuyPeriod` (`approvedAt`, `approvedById`, `baseline`). They go first because the worker deploys within seconds and reads `Organization`, which gains four columns here, and Prisma asks the database for every column of a row it reads, so even an additive column breaks a worker that starts first; web-v2 only migrates when it boots, which is slower. Verify by query, not by the deploy log.
- Three worker HTTP crons: `buy-plan-match` (hourly at :20), `buy-plan-daily` (12:45 UTC) and `planning-sequoia` (hourly at :35).
- No new Novu workflow. The catalog stays at 100, and nothing needs `pnpm provision:novu`.
- Integrations API 1.4.0 needs no migration and no setting. Buy planning over the API is off for an organization until its Buy planning is on, and a key gets the new scopes only when someone ticks them. The worker loads the shared package when it starts, so it must restart to know the three `BUY.*` event names (a deploy does it; a local worker started with `tsx watch` does not reload that package and needs a restart by hand). The daily Buy cron (`buy-plan-daily`, 12:45 UTC) now also sends `BUY.TARGET.ENDED`, on its existing schedule. The closed-quarter rule and `OFFERS.ACCEPTED` are what an existing key or endpoint can notice; see "Changed for existing keys" above.
- Demo and real organizations are two worlds wherever a listing reaches a buyer: Planning's matches, instant alerts and daily summary, the target panel, the Buy analysis, the Marketplace strip and badges, the Cypress desk's live matches, Sequoia's inputs, and Marketplace saved-search alerts (the alert worker, the daily digest, the backfill on a new alert and the two staff triggers). A demo vehicle never alerts a real person, and a real vehicle never alerts a demo one. Marketplace browsing is unchanged.
- The demo world (`pnpm planning:demo`) can now run on staging, behind an explicit opt-in (`PLANNING_DEMO_TARGET=staging` and `CONFIRM=YES`, inside the staging web-v2 container), and refuses production three ways: the Railway environment, `NODE_ENV=production`, and a database host or name that reads as production. It adds one more example, a listed Altima SV that two dealers plan to buy, so the Planned demand card can be shown.
- Automations are in the worker and the shared package, so a local worker must restart to pick them up (a deploy does it). No migration, no setting. Existing any-field flows stop running on plan events as soon as it deploys, and flows built on Offer accepted start running for every accepted deal (a deploy of web-v2 does the latter; the worker already reads the trigger); see "Automations".
- The Newsroom adds two web dependencies (`react-markdown`, `remark-gfm`), no migration and no setting. Every ship adds front matter to its release write-up and runs `pnpm newsroom:generate`.
- Nothing else to set: no new environment variable.
v3.3.1
2026-10-06Buyers see how a seller decides, never where
Fixed
- Marketplace (signed-in and public), storefronts, direct links, promotions: buyers no longer receive the seller's auto-accept, review range or floor. They get three flags: offers are reviewed, instant purchase at the asking price, or neither.
- Offer panel: "will auto-accept" appears only at the asking price with instant purchase on. Below 90% of the ask it says "Offers well below the asking price are usually declined", worked out in the browser from the public ask.
- Buyer Hub: offer rows, the Activity tab and price notices carry no threshold or floor. Notices stored earlier are cleaned on read.
- Buyer Hub Updates feed and the closing list now show buyers pricing changes in words only.
- Price-change notices to buyers no longer reveal the floor: every buyer with an active offer on a vehicle gets the same "Pricing updated" notice when its floor changes, not only the buyers whose offer crossed it.
- Cedar's price range is no longer sent with a marketplace vehicle.
Kept
- Cypress staff see the seller's floor and thresholds on the signed-in vehicle page, on a line marked "Cypress only".
v3.3.0
2026-10-06Seller terms buyers accept, and closing paperwork that looks like the seller's own
Sellers can now publish their own terms, such as Fox Rent A Car's Vehicle Sale Return & Arbitration Policy, and every buyer must accept them before making an offer or buying. The accepted version is recorded on the offer and printed, word for word, on a Seller Policy the buyer e-signs at pickup. Fox's own Bill of Sale, its Manheim variant and its Gate Release are rebuilt as templates, and a deal can switch between Bills of Sale in Deal Setup.
Seller Terms & Conditions
- Optional, per seller, off by default. Org admins edit them in Settings → Terms & Conditions; Cypress managers edit any business org's from the admin org page. Title up to 120 characters, text up to 4,000, with bold, italic, underline and bulleted or numbered lists.
- Versioned. Every save that changes the text is a new version; the history shows who saved each one and its exact text. Turning the terms off and on loses nothing.
- Shown on the seller's listings — marketplace, public pages, storefronts and direct links — with "Read full terms".
- Accepted before every commitment. Make Offer, Buy Now, the no-account express offer, re-offers and accepting a counter on an offer made before the terms existed all require ticking the box. The server refuses otherwise; if the seller changed the terms while the form was open, the form shows the new text and asks again.
- Evidence on the offer: the version accepted, when, from which IP and browser, and whether Cypress accepted on the buyer's behalf. The seller's offer panel shows "Accepted … v2 · date".
- Terms too long to print on one page are refused at save, so the signed copy never cuts a clause off.
The Seller Policy, signed at pickup
- A new closing document, with a Cypress template. An organization opts in by adding a Seller Policy slot to its Pickup step; it is only allowed on Pickup, only for an org with active terms, and is always required.
- It prints the exact version the buyer accepted, captured when the closing starts and never refreshed. It is generated and sent to the buyer for e-signature when Pickup opens, and Pickup cannot complete until the buyer signs. A Cypress manager can waive it per deal.
- Never issued wrong: a policy that would print blank, cut off, or with altered terms is refused; only a Cypress manager can upload into its slot.
- The designer preview of a Seller Policy shows the organization's own terms.
Fox Rent A Car's paperwork
- Fox Bill of Sale, Fox Bill of Sale (Manheim) and Fox Gate Release, rebuilt from Fox's paper forms, with Fox's order ID, sales representative and manager as organization fields, and a seed script to install them per environment.
- Choose the Bill of Sale per deal. A seller with more than one published Bill of Sale picks it in Deal Setup; the organization's default is preselected. In the designer, a Bill of Sale is marked "Default Bill of Sale for this organization".
- Buyer phone and buyer contact come from the buyer's profile, with optional inputs in Deal Setup.
Fixed
- Assigning a Bill of Sale template to the Bill of Sale step added a second required slot, so every closing needed two Bills of Sale; removing the default archived the slot itself. It now repoints the one default slot.
- Cloning the base closing checklist from the designer dropped "must be signed by Pickup" from the gate pass.
- Admin org settings: Title & Transport, Document Templates, Terms & Conditions and Activity sit on the same white card as the other tabs; Terms & Conditions saves from the sticky bottom bar; the Documents tab no longer flashes a large spinner.
v3.2.0
2026-10-06One email per bulk action, and campaigns that never mail a sold car
Three hotfixes ship together. Bulk actions no longer flood inboxes: on October 5 a bulk launch of 121 Fox Rent A Car vehicles sent 968 "Vehicle Listed" emails, 121 to each of Fox's seven Fleet Managers and its account manager, with no VIN to tell the cars apart. Vehicle notifications now arrive as one email per person and type, with a table of the vehicles. Marketing campaigns stop mailing vehicles that are sold or in a deal, and pause themselves when nothing is left to sell. Notifications meant for Cypress, which looked for Cypress staff inside the seller's own organization and so reached nobody at most accounts, now copy the account's assigned account manager.
Vehicle notifications arrive bundled
- One email per bulk action. Every notification about a specific vehicle waits up to 30 seconds after the last one of its kind for the same person (5 minutes at most), then goes out as one email: "Vehicle Listed — 121 vehicles", with a table of vehicle, VIN, Unit #, what happened, and a link (50 rows, then "and N more" with a link to see them all). A single notification arrives exactly as before.
- Whatever caused it. Bulk buttons, automations, the partner API, imports and scheduled reminders are all covered, with no change to any of them. Buyers matching an alert get one "new matches" email instead of one per vehicle.
- Same notification, same preferences. A bundle is sent as the notification type it bundles, so each person's email and in-app settings apply unchanged. No new Novu workflow; the catalog stays at 100.
- Never held: the express-offer confirmation and fee-invoice emails, and anything with an attachment.
- Every vehicle email names its vehicle. About forty notifications that carried the vehicle only in their data now get the VIN, Unit # and a link to the car, like the rest.
- Built not to lose mail. If bundling cannot hold a notification it sends it at once; a failed send retries for about an hour with exactly the same vehicles; a sweeper rescues any group whose send went missing. `NOTIFICATION_BUNDLING=off` switches it off without a deploy.
The account manager gets the Cypress copy
- Notifications meant for Cypress now reach a person. Pending-approval and stale-review reminders, pending labels, stuck inventory, due-diligence deadlines, the e-signature notices for Cypress, the Cypress copy of "Fleet Import Complete", "Vehicles Ready for Listing", "Valuation changed" and "Offer expired" looked for Cypress staff inside the seller's organization, where they are not members. At most accounts they reached nobody. They now also go to the account's assigned account manager.
- Default account manager. A new setting in Admin → Settings → General names who gets the copy for accounts with no account manager assigned.
- Everyone who received these notifications before still does, and nobody gets the same notification twice.
- Reminders once a day. A reminder about a vehicle now goes out at most once per day per reminder type, instead of on every 15-minute sweep.
Campaigns never mail a car a buyer cannot buy
- Available vehicles only. Campaign emails, the featured vehicle, the attached offer sheet, the "N more" count and audience matching include only vehicles that are listed or under offer. Sold vehicles, and vehicles with an accepted offer, in due diligence or ready to deliver, are left out — in hand-picked promotions, filter-based promotions and inventory digests alike. Nothing is removed from a promotion: a vehicle whose deal falls through comes back on its own.
- A campaign with nothing to sell pauses itself instead of sending an empty email, and resumes at its next scheduled send once a vehicle is available again. The campaign page says so, the Marketing grid shows "no vehicles" (separate from a manual pause) and Mission Control lists it.
- A manual pause is kept. Pausing a campaign records who and when, is never undone automatically, and pausing an auto-paused campaign ("Keep paused") keeps it off. Activating a campaign with no available vehicles is refused with the reason.
- Counts read "8 available of 11" in the campaign editor, the promotion picker and the promotions grid.
- Promotion lists count available vehicles in one query per page, and bulk activate/pause runs at most four requests at a time.
Fixed
- Regenerating a document over live signing links — ticking "I understand the links already sent will stop working" closed the Generate from template modal and discarded the edits (for example, a corrected mileage on a bill of sale). The modal now stays open and the regenerate goes through.
v3.1.0
2026-10-01Planning over the API, and marketing mail straight to Resend
Two things ship together. The partner API catches up with Planning, which it should have done in 3.0.0: in three contract steps (API 1.1.0, 1.2.0, 1.3.0) it stops treating planning stock as Sell stock, tells an integration what its key may do, and then lets a fleet's own systems run Planning end to end. And marketing mail — promotion campaigns, CRM campaigns and one-off outreach — now goes to Resend directly instead of through Novu, so a busy moment no longer drops sends silently.
Planning over the API (Integrations API 1.3.0)
- Push the fleet in. `POST /api/v1/vehicles` (single, bulk, `?mode=upsert`) accepts `lifeStage: OPERATION` (in service) or `PLANNING` (benched on the plan as a candidate), with an optional purchase & financing block checked before anything is created. Every PLANNING vehicle of one push is benched in one plan version. No valuation is bought; the daily sweep values new planning stock over the following days.
- Keep it current. `PATCH` updates a planning vehicle's details through the same save the app uses; per vehicle, `ownership` (read and replace, with the calculated balance and payoff), `odometer-readings` (dated, km converted, a rollback refused unless forced), `cost-events` (a retry with the same `externalRef` never records twice) and `departure` (staged for Cypress review, like every seller archive).
- Read the plan. `GET /api/v1/planning/plan` (windows and tracks), `/planning/vehicles` (paginated, filterable, with the latest valuation and plan advice) and `/planning/vehicles/{id}` (the app's verdict, expected realized value and trend).
- Drive the plan. Move vehicles into a sale window, to the bench or back to operation; tracks; parking until a quarter; blockers (add, resolve with evidence, edit); the cohort hand-off to Sell as preview, then confirm; and demotions back to the bench. Every write is one plan version, audited as `API · <key name>`; a write that loses the race answers `409 PLAN_BUSY` with `Retry-After`. A window id read from an earlier plan version is followed by its lineage, so a retry needs no re-read.
- `PLAN.*` webhooks — entered, scheduled, window due, window missed, blocker added and resolved, handed to Sell. Cypress dialect only (a MANHEIM subscription listing one is refused with `422` on every surface), delivered only to subscriptions that list them; no new one is sent once the organization's Planning is switched off — deliveries already fanned out still deliver and retry.
- The app's planning actions and the API now run the same rules: each planning write is one shared function called by both, so the two cannot drift.
Know what your key can do (Integrations API 1.2.0)
- `GET /api/v1/organization` — what your organization and this key may do: products, channel grants, billing standing, delegation, and one entry per capability, refused with a reason when it is not allowed. It answers for any valid key, so an organization still in onboarding learns why everything else is refused.
- Choose a key's scopes on the Integrations page, and change them later without rotating the key (audited). New scopes `planning:read` and `planning:write`, available when the organization has Planning; existing keys don't gain them.
- Every refusal carries a stable `code` — `ORG_NOT_ACTIVE`, `SCOPE_MISSING`, `PLANNING_NOT_ENABLED`, `CHANNEL_NOT_GRANTED`, `PLANNING_STOCK`, `LIFE_STAGE_READ_ONLY`, and the planning codes `IN_SELL`, `RETIRED`, `PLAN_BUSY`, `ODOMETER_ROLLBACK`, `NO_PLAN`, `PREVIEW_STALE`, `WINDOW_CLOSED`, `WINDOW_GONE`, `SELLING_BEGUN`, `RESOLVED`, `QUARTER_PASSED`. The route guards and the discovery endpoint use one rule, so they cannot disagree.
- Bulk push summaries gain a `refused` counter — the count of items answered `403`.
The API keeps to Sell stock (Integrations API 1.1.0)
- Every API vehicle carries `lifeStage` (Operation, Planning, Remarketing, Post-sale, Retired), on reads and in webhook bodies, on both the Cypress and Manheim-compatible surfaces.
- Planning stock is off the Sell surfaces. Vehicles in Operation or Planning no longer appear in API vehicle lists or reads unless a `planning:read` key asks for them (`?lifeStage=OPERATION,PLANNING`), and every Sell action on them — prices, listings (including `IMMEDIATE` launches), deletion, sold-elsewhere — is refused. Before, a planning-only vehicle could be listed straight to market through the API, skipping its plan's hand-off.
Changed for existing keys and subscriptions
- Planning stock is refused on Sell actions with `409 PLANNING_STOCK` (`403 PLANNING_NOT_ENABLED` at an organization without Planning); a `POST` for a VIN already in Planning no longer returns the existing vehicle's `id`. `PATCH` on planning stock by a key without `planning:write` answers `403 SCOPE_MISSING`.
- An embedded `listing`/`offering` on a vehicle/unit push needs the `listings` scope, not just `vehicles` — a vehicles-only key answers `403 SCOPE_MISSING` instead of creating the vehicle with its listing silently dropped.
- Purchase & financing on existing keys. `GET`/`PUT /api/v1/vehicles/{id}/ownership` needs only the `vehicles` scope for a vehicle in Sell, so a key with the default scopes can now read and replace the purchase & financing record of its organization's own Sell vehicles.
- Vehicle webhooks about planning stock — `VEHICLES.*` for a vehicle in Operation or Planning — follow the `PLAN.*` rules: only a Cypress-dialect subscription that lists the event, only while the organization has Planning. Unfiltered and MANHEIM subscriptions never receive them, including the `VEHICLES.DISPOSITION.RESOLVED` of a planning vehicle archived from the app, which earlier versions sent to every subscription. When a cohort confirm hands vehicles to Sell, each sends a plain `VEHICLES.UPDATED` to every matching subscription.
- Off-platform sales send `CLOSING.COMPLETED` with `offPlatform: true`, instead of the uncatalogued `CLOSING.PROCESS.COMPLETED` that only unfiltered subscriptions received.
Marketing mail straight to Resend
- Promotion campaigns, CRM campaigns and one-off CRM outreach go to Resend directly — no Novu queue in the marketing lane, so a burst no longer turns into silent `429` drops.
- A send is recorded as sent only with Resend's id, and as failed with Resend's own reason; no recipient, send or outreach is left without an outcome.
- Retries never double-send. Batches carry stable keys, and a recipient Resend already has is never sent again.
- Even pacing. One Redis-shared rate limiter spaces marketing mail evenly across workers; test sends go first, campaign sends retry up to five times, and limiter errors are retried.
- One envelope for every campaign — tag, unsubscribe headers and reply-to (one-off outreach replies to the salesperson); outreach carries the postal address, and inventory mail gains its unsubscribe footer.
- The promotion email template no longer needs Novu provisioning.
Fixed
- The Manheim-compatible bulk push refuses an item whose embedded offering names a channel the organization has no grant for (`422 CHANNEL_NOT_GRANTED`, unit not created, the batch continues). Before, it created the unit without the offering and answered `201`.
- Promoting a cohort through Cedar fires the same "handed to Sell" automation event as the Planning page.
- The app's launch function refuses planning stock (defense in depth).
Developer docs
- New pages: Planning API, Your organization and key, and Errors (one error shape, every status, the stable codes, retry or fix). The API reference opens with what changed in 1.1.0, 1.2.0 and 1.3.0; the lifecycle, webhooks, de-listing and quickstart pages cover planning stock.
- The Bruno and Postman collections in the integration kit gain `0-organization` and the twelve-request `5-planning` folder (51 requests).
v3.0.0
2026-09-29🧭 Planning: deciding what to sell, and when, before it's sold
Until now Cypress started at the moment a fleet had already decided to sell a car. Everything before that — which vehicles leave this quarter, in what order, against what target, and whether holding one more month earns or burns money — happened in spreadsheets, and arrived in Sell as a pile of drafts with no reasons attached. This release adds that step to the platform. It is the first time the product is two stages instead of one, which is why it is a major version.
Added — Planning (Sequoia)
- 🗓️ The quarter plan. Goals with real numbers — units, cash by a date, fleet shape, renewal pace — and monthly targets per lot. A plan is a draft until it is approved; the approved plan is the baseline every later change is measured against, and a quarter closes on its own.
- 🧾 Lots, the way the fleet was bought. Same-model vehicles cut into mileage bands ("20 of the 2021 Sentras, 60k–75k, in October"), ranked within the lot with a stated reason for every order.
- 🪑 Every vehicle has a place. In operation, candidate, parked, scheduled, handed to Sell. Nothing needs setting up first: the moment a fleet has vehicles, a base plan exists and every vehicle is in it; vehicles already in market are adopted rather than ignored.
- 📅 Sale windows. Six two-week windows always live, each with a hand-to-Sell-by date. Sequoia proposes how to spread a lot across them, honest about what it couldn't place; you apply the whole proposal as one plan version, or drag a lot between weeks yourself.
- 🚚 The hand-off to Sell. A window's vehicles enter Sell as intake with the plan value they were scheduled at frozen, so what the plan expected and what the market paid can be compared later. A vehicle still unsold at the window's close is carried into the next one, with the miss counted.
- 🛤️ Tracks and groups. Tracks hold vehicles with a posture (list at, above or below market; how fast); groups are rules across lots with Sequoia's suggested groupings. A track's label follows the vehicle into Sell.
- 💵 Hold or sell, in money. One chart comparing releasing a vehicle now against holding it — running cost per class, per-vehicle overrides, and the vehicle's own depreciation curve where Cypress has enough data (per-model steepness across the platform, a class prior otherwise). The value always says what it stands on.
- 📈 A market signal. Cypress-wide realization and trend for the same model in the same region, falling back to model, then class, with a floor of five sales — and it says so when there isn't one.
- 🧱 Blockers and readiness. Payoff, evidence, arrivals, authority — tracked per vehicle, with one readiness word used everywhere.
- 🔍 Review. Each closed window writes a review of what the plan expected against what happened; you record a verdict, and the next plan version starts from it. The execution mirror and the shortfall ladder show what 50, 75 and 80 percent of a goal actually cost.
- 🔔 The plan's decisions are automation events. Entered, scheduled, window due, missed, blocker added and resolved, handed to Sell — and a flow now says which lifecycle stage it works in, so a remarketing flow never touches planning stock.
- 🧑✈️ Cedar knows the plan. A planning brief in goal language, and cohort promotion to Sell on Cedar's confirm rail.
Added — Planning as a product
- 🔑 Switched on per organization. Off for every organization at release; Cypress staff switch it on from the organization's Products row, audited, with the date it was switched on. Switching it off hides Planning and deletes nothing — plans, lots, windows and planning-only vehicles come back exactly as they were.
- 🔒 A locked teaser for everyone else. Organizations without Planning see it in the navigation with a lock and a page that explains it, with Request Planning — one click that tells Cypress once. Staff acting for such an organization see the same page, plus a switch-on button.
- 🧹 Nothing leaks when it's off. The planning APIs refuse, Cedar has no planning tools, automations never reach frozen planning stock, the crons skip the organization, and Cedar's own sell/hold advice comes back on vehicles the plan used to advise.
Added — One vehicle across its whole life
- 🧬 The business life stage — Operation, Planning, Remarketing, Post-sale, Retired — derived for every vehicle, never stored. The stepper shows the whole life, and one side panel serves every stage, focused on the stage the vehicle is in.
- 🏦 Purchase & financing you can see and edit — the balance owed and the payoff calculated from the loan terms, balloon credit included.
- 🧾 The planning import reads every purchase option, with a template, and says what a missing ZIP costs you (a national value instead of a regional one).
Added — Documentation 2.0
- 📚 Three manuals: Platform, Planning, Remarketing, in the order a vehicle lives them — each with an overview, how-to guides, practices and the reference chapters. Every old link redirects.
- ✨ Cedar on every manual — a Cedar-written overview, and "What Cedar can do here" generated from Cedar's own registry, so the docs can't promise an action Cedar doesn't have.
- 🧭 Twenty-six new guides — nineteen how-tos and seven practices, every button, message and number checked against the product, with fresh screenshots — and step-by-step process diagrams for Planning.
Changed
- 💸 Black Book is paid per vehicle, so values refresh on a slower clock. A live listing's market value refreshes every 30 days instead of 14, and a planning vehicle's every 90 days instead of 30 — the vendor re-marks the same VIN by a median 0% a month, and even the batch endpoint bills each vehicle. Each vehicle keeps its own clock and the daily sweep is capped, so a large import spreads over several days instead of landing at once. A planning value is marked *aging* after one and a half cycles (135 days).
- 🗣️ One voice in Sell. For a fleet with a live plan, the plan is the hold/sell engine and Sakura advises inside it; plan badges on Seller Hub and Fleet rows, and a read-only Plan tab.
- 🧮 Grids agree on one leading order — vehicle, state, condition with mileage, location — and the vehicle column stays put while the grid scrolls sideways.
- 🏷️ The condition score says its source, so an import default is never presented as an assessment.
Fixed
- 🏗️ The branch builds. A schema exported from a planning route made the production build fail; a guard test now fails any route that exports something Next.js doesn't allow.
- 🕰️ Days listed has one rule and every listing path records its start; Sakura reads every vehicle being sold, and a read goes out of date when the vehicle moves on.
v2.9.8
2026-09-28🗂️ One license per state, and a counter Cypress can answer
Two fixes to places where the platform held a single slot for something the business has more than one of.
Added
- 🪪 An org can hold several dealer licenses — and several other documents. Each is its own card with the same Review, Replace, history and AI analysis a single document had, and Add another license / Add another document sits under the list. Replacing one license supersedes that license only; the others keep their status. Works the same in the org's own Settings → Documents and in the org's Documents tab for Cypress. The W-9 stays one card, because an org has one tax id.
- 🗑️ A license or document can be removed. The license of a state the dealer has left no longer has to sit on the profile forever. Org admins remove from Settings, Cypress from the admin tab, and every removal is written to the activity log. The only license on file is replaced rather than removed, since it is required; a W-9 is never removed.
Fixed
- 🤝 A Cypress Manager acting for a buyer can answer the seller's counter. Accept, Counter back and Withdraw were hidden because the offer list told the panel the viewer was the Cypress org rather than the org being acted for, so no offer ever looked like the viewer's own. The panel now sees the acting-for org, and the buyer's delegation is still enforced where it always was: an org that has not handed buyer actions to Cypress refuses them with that reason.
Changed
- ✅ "All required documents approved" means one approved license is enough. The W-9 must be approved, at least one dealer license approved, and none rejected. A second license still in review shows as pending on its own card; it does not take an approved org back to pending.
v2.9.7
2026-09-23⚡ The send that stopped taking the site down
Twice this month a campaign send took production down, and the number that explained it was not in the database. It was in the mail: a corporate link scanner opens every URL in an email the moment it arrives, so 2,100 recipients became thousands of clicks in a burst, each one a tracking write and a full page render. The first fix bounded how many of those could touch the database at once. It stopped the errors and left the slowness, because every recipient of a send shares one row and the four lanes still queued behind its lock.
Fixed
- 🧮 A send's counters are written once a second, not once a click. Every recipient of a send shares one `CampaignSend` row, and the rollup was an `UPDATE` on that row per event — so the tracking lanes serialised behind a single write lock and a burst drained at the speed of one. The Resend webhook, which waits for its event, was running at a 101-second p95 while the queue cleared, and the latency alert was still firing ninety minutes after a 14:00 send. Increments accumulate per send now: a 500-click burst is one statement instead of five hundred queued ones.
- 📬 Resend gets its answer before our database does. The webhook awaited the tracking write, which meant queueing behind every scanner click of the send — far past the point where the provider gives up and retries, so a slow handler manufactured more of its own load. Deliveries settle after the response now. Anything that can suppress an address — a spam complaint, a bounce — is still written before the 200, because a complaint dropped by a restart is not recoverable: the provider will not retry an event we accepted, and reconciliation deliberately never suppresses.
- 🔗 One recipient's scanner stops flooding the tracking table. Nothing in a single request identifies Microsoft Safe Links — it arrives half an hour after delivery, from hundreds of addresses, wearing an ordinary Chrome user agent. What gives it away is the shape: twenty links in a few seconds. Past five clicks in ten seconds a recipient's clicks are served and not written down. A buyer opening six cars over a few minutes is untouched, and their first click is always recorded.
- 🔑 One trip to the login service per page, not one per request. Asking who is signed in built a fresh client twice on the happy path, each re-fetching the OIDC discovery document — 16,745 discovery fetches, 15,705 token calls and 8,695 userinfo calls in a week, every one on a request somebody was waiting for. A resolved session is now reused for thirty seconds and shared across a page's parallel requests; a visitor with no session cookie never asks at all, which is most of the public marketplace's traffic.
- 🧲 The sidebar stopped prefetching pages nobody had asked for. Every page behind it is dynamic, so a prefetch is a full server render. The whole sidebar sits in the viewport at once, so one dashboard load fired ten of them alongside its own six API calls — 4-6 seconds on a quiet morning. Hover still warms the link, which is the moment the intent is real.
- 🖼️ A vehicle with no photo says so, instead of looking broken. 68% of listed vehicles have no photograph, so this is not an edge case — it is the marketplace's resting face, and it read as a rendering failure. One placeholder replaces eleven hand-drawn variants and the bare grey boxes on list rows, gallery tiles and the promotion picker. A photo that exists and could not be delivered now says "Photo unavailable" rather than passing as a car nobody photographed: an expired link and an empty listing used to be the same `null`, which gave a delivery bug 247 vehicles to hide behind.
- 🗂️ The admin notifications page lists the catalog it is testing against. It read "85/87 provisioned" against an instance holding a complete hundred. One row named a workflow that has never existed, one named a workflow deleted in August, and fifteen live workflows — campaign sends, account confirmation, credit-limit and spend alerts — were missing entirely, so the screen built for testing notifications could not test them.
Added
- ⚡ The public vehicle page is rendered once and reused. It was rebuilt from scratch for every visit for one reason: it resolved the session while rendering, to choose between the offer form and the register gate. Everything else about it is identical for every visitor. That question moved to the browser, so the page is served from cache — measured on the production build, the first request takes 872 ms and every one after it 3.5 ms. A scanner sweep now costs a cached response instead of a render, a database read and a login round trip.
- 🔎 Every notification says which car it is about. The vehicle descriptor was rebuilt inline at 37 places with six different fallbacks, and the VIN appeared only when year, make and model were all missing. Messages now carry a short VIN, emails carry the full VIN and Unit #, and a notification that named a vehicle but linked to a list — the entire offer family landed on `/buyer-hub` — now links to the vehicle itself.
- 📋 Two emails that had been sending blank rows are wired. The offer email's Details table was bound to fields nothing in the platform ever set, and the buyer-alert email's vehicle card was never filled. Rows with no producer omit themselves rather than printing an empty label.
Changed
- 📈 An imported vehicle with no stated condition is scored 3.8, not 3.0. These are running, saleable vehicles, and "Good" describes them better at the top of its band than the bottom. It is the same Black Book tier, so nothing is valued differently — only what a seller sees. The value now lives in one constant instead of three copies that had already been asked to stay in sync, and a test names the tier it maps to, so moving it across 4.0 has to be a decision rather than a one-character diff.
v2.9.6
2026-09-18🤝 The closing that runs itself
Nine steps became six, and the three that went away were not simplifications — they were places the platform had been asking a person to tell it something it already knew. A buyer attesting they had sent money, a Cypress Manager reading a VIN back off a document Cypress had just generated from that VIN, a buyer confirming a title arrived on a step of its own. What is left is the work that genuinely needs a human: signing, confirming money landed, releasing a vehicle, collecting it.
Added
- 🛤️ Six steps instead of nine. `FEE → BOS → CONFIRM_FUNDS → GATE_PASS → TITLE → PICKUP`. Buyer Signature folded into the Bill of Sale, which now collects both signatures in one step, in the order the template declares. Payment is gone: the buyer pays against the instructions on the Bill of Sale, and what the rail records is the seller confirming the money *arrived*, which is the only half worth attesting. Title Receipt folded into Vehicle Pickup. Deals opened before this keep their nine rows and run beside the new shape — every decision keys off a step's `stepKey`, never its number or the count.
- ✍️ Documents write themselves. The Bill of Sale renders when both sides confirm Deal Setup; the gate pass renders the moment the seller confirms funds — so the release paperwork is waiting at its step rather than behind a Generate button somebody has to find. Both complete on their signatures, not on an approval.
- 💳 A card on file, and no payment step at all. Save a card in Billing, or tick *"Save this card for future commissions"* at checkout (on by default), and every later commission is charged off-session the moment its invoice is raised. The rail never locks.
- 🔔 The seller is told before the first charge, not after. Confirming the seller's half of Deal Setup is what raises the invoice and therefore what triggers the charge — so that half of Deal Setup now names the amount, the card, that there is no separate payment step, and how to opt out. Shown only to the seller, only before confirming, and only while no invoice exists.
- 🧾 AUTO-PAID, where the fee step used to say a bare *Paid*. A charge collected off-session has no click behind it, and a seller reconciling a card statement needs to recognise it. New `CommissionInvoice.autoChargedAt` carries the fact and the moment.
- 🚪 Opting out is one action, either side of the table. The seller removes the card in Billing → Payment settings; a Cypress Manager removes it from the org's admin page on their behalf, for a seller who asks by phone. It takes effect on the next commission. A declined card falls back to the ordinary invoice-and-pay path, so a failed auto-charge never silently loses a fee.
- 🧭 A late Deal Setup edit shows what it will cost, then does it. Deal Setup stays editable to the end — a pickup address genuinely changes. But changing a fact an already-issued document prints makes that paper wrong, so saving now reopens the step holding it and regenerates it. Before the save you see which documents are invalidated, which are already signed, how many signing links will be cancelled, and which completed step will reopen. Only documents that genuinely print the changed fact are touched: the check is against each template's own bound fields, so correcting a bank contact does not reopen a gate pass.
- 🖊️ Ink is recorded per signature, with the moment it was obtained. A gate pass is signed twice by two parties at two moments — the seller authorises the release, the transporter signs on collection — and the transporter has no Cypress account. So the record is keyed by *signer*, not by who filed the scan or from where: the seller scans the countersigned pass and Cypress records whose ink is on it and when.
- ❓ "Has the buyer's cheque cleared?" The 10-day hold protects the title, which ships *after* the vehicle is released — so on a cheque deal a car can leave on money that may not be there. Cypress does not block that; a seller may hand over on trust. From the moment a cheque is recorded, the Gate Pass and Pickup steps carry the question, and answering releases the title's hold early.
- 📍 Plate and transporter are asked at Deal Setup, with everything else, instead of at the gate. The release document was being written while somebody stood at a barrier reading it.
Fixed
- 🔌 "Submit signed copy" had never worked. The Signatures panel posted `signedCopy: 'true'`; the upload route tested `=== '1'`. The one control whose entire job is filing the ink scan was silently downgraded to an ordinary replacement — no provenance, no signature recorded — and the step it was meant to finish could never complete. Both sides now agree, and the flag is parsed leniently, because a form field meaning *yes* should not hinge on which spelling of yes a caller picked.
- 🚧 The seller's ink was signing for the transporter. A scan marked the whole document executed, so Pickup stopped asking and a vehicle could be collected with nobody having signed for it.
- 🧮 A signed copy could not finish a gate pass. The completion call was guarded to the Bill of Sale, though the function it guarded handles both steps — so a submitted pass landed, was accepted, and changed nothing.
- 🪧 A half-signed document reported itself finished. The signatures panel answered *settled* the moment any scan arrived, hiding the outstanding signer; and a card badged Signed sat above a signature the panel still described as pending, with its timestamp in hand and unshown.
- 🗺️ Three surfaces confused where a vehicle *is* with where it is *collected from*. The closing header, the Pickup step card, and the deal context handed to the document reviewer all drew the inventory location under a pickup heading. The reviewer consequently reported a correct gate pass as having a critical discrepancy — a confident false alarm, which is the kind that teaches people to ignore the real ones. All three read the Deal Setup pickup now, and the field that started it is renamed *"Where the vehicle is collected from"*.
- 👤 A Bill of Sale the seller had signed still badged "Seller". `lib/closing-responsibility` had the right answer and five surfaces never called it.
- 🔕 The loudest case was the silent one. A closing moved its collection point 2,500 miles after the gate pass had been issued and signed; the platform knew the paper was wrong and said nothing, because the staleness banner skipped completed steps. It no longer does.
- 🎯 Staleness is judged by what a document prints, not by a revision counter — otherwise correcting a bank phone number marks every older document stale, and once a rollback is forced off that signal it would void a signed contract over a typo.
- 🧷 Confirm Pickup stayed clickable with a signature outstanding. The server refused it; the button had never heard of the panel warning about it, directly above.
- 👻 The checkpoint card vanished when satisfied — the seller submitted the countersigned pass and the panel they had just used disappeared, taking the signature record with it.
- 💳 The commission card was filed inside ACH Batch Billing in the CM's view, between a bank account and a credit limit, when it is a different rail entirely.
Changed
- 🛡️ Cypress no longer approves the Bill of Sale or verifies the gate pass. Both are generated by Cypress from facts both sides already confirmed, so the review was Cypress checking its own arithmetic. Signatures complete the steps. `verify_gate_pass` is gone; `approve_bos` survives for the uploaded-document path.
- 🔁 One rollback implementation. The CM-only route's core moved to `lib/closing-rollback`, so the forced rollback above and the manual one are the same code — it voids signing links and supersedes paper, and two copies of that is the worst possible place for drift.
- 🔢 A completed step keeps its number. The green fill already says *done*; the number says *which*.
- 📚 The manual is rewritten for the new shape — the closing chapter and its rail diagram, the money chapter (including card on file end to end), the rollback chapter, and the developer event catalogue, which now tells integrators to key off `stepKey` and never the step number, because both shapes run side by side.
- 🧹 Dead code removed, all verified zero-reference first: `generate_bos` (unreachable *and* broken — it looked up a step v3 does not create), `use_seller_bos`, `createBosSubmission`, `getSapeleInsight`, five dead props, and a duplicate of the ACH sweep that had already drifted two days out from the worker's real schedule.
Operational
- Two migrations, both additive and both safe to apply before the deploy: `20260917100000_org_card_on_file` (four nullable columns on `Organization`) and `20260918090000_commission_auto_charged_at` (one nullable timestamp on `CommissionInvoice`). Apply before pushing, per the v2.9.3 order.
- `pnpm migrate:closings-v3` must be run per environment, and has not been run in staging or prod. It folds in-flight closings onto the six-step shape (PAYMENT → CONFIRM_FUNDS, TITLE_RECEIPT → PICKUP, BUYER_SIGN → BOS) and re-opens steps stranded in a review that no longer exists. It writes a backup JSON before touching anything and runs each closing in its own SERIALIZABLE transaction; dry-run first, then `--apply`, then verify by query. Until it runs, those closings keep their nine rows and work — the platform reads `stepKey`, not step numbers.
- `pnpm seed:document-templates` now creates the Pickup delivery-receipt slot as optional rather than required. The seed never overwrites live rows, so existing environments keep whatever they have; change it in the checklist editor.
- No new Novu workflows. The catalog is at its 100 cap and a 101st silently evicts one — every notification in this release reuses an existing workflow.
- Stripe: no dashboard change. Saved cards use SetupIntents with `usage: 'off_session'` on the existing account; the auto-charge is an off-session PaymentIntent settled through the same `applyInvoicePayment` funnel every other collection path uses.
v2.9.5
2026-09-16🖼️ Seeing the email before it sends
A promotion went out with a square logo as its hero. In the inbox that logo is not a logo, it is a 532px band of white above the first car — because the email is the only surface that renders the image at the shape it was given, and nothing in the product had ever shown that shape. The campaign screen's preview drew the author's prose and stopped there; the hero was a 144×80 thumbnail with `object-fit: cover`, which hides aspect ratio by definition. The one place the framing was visible was the sent email.
Added
- 📬 The campaign screen can show the whole email. Not the prose — the email: the Promotion badge, the hero, the vehicle cards, the featured card, the CTA, the reply line, the footer, beside its own subject line, as an inbox will draw it. Rendered server-side through the *same* Liquid template, the *same* payload builder and the *same* vehicle resolver a real send uses, then dropped into a sandboxed iframe so email HTML written for Outlook cannot inherit — or fight — the app's stylesheet. Three things differ from a send, all deliberate: a sample recipient instead of a person from the book (toggle between a full contact and the email-only kind, which is most of a book), image URLs signed for the in-app hour rather than the send's week, and a tracking token that was never issued — it contains a dot, and url-safe base64 never does — so nothing in a preview can reach a campaign's analytics. It saves your edits first, because the server renders the *saved* campaign and a preview showing the previous draft as confirmation of what you just typed is worse than no preview. "Test now" is still the end-to-end check; this is the one you can run fifty times while writing the copy.
- ✂️ A hero image has two framings now, one per surface, and you can see both. The two surfaces want opposite things. Every public surface cover-crops to a fixed box and never shows the whole picture — the marketplace strip (full width × 140px, cut at 30% from the top), the promotion page banner (220px), the cards, the thumbnails. The email renders the native aspect ratio at 532px wide, so the stored shape *is* the inbox shape. A banner cut for a 140px strip is a letterbox in an inbox; a 16:9 hero is a slice the strip takes from its middle. Each now gets its own crop — pan-and-zoom behind a fixed frame, with aspect presets that differ by surface, and the marketplace offering nothing narrower than 16:9 because a tall crop only gives the strip more to throw away. Both are always cut from the untouched original, never from each other.
- 🔍 And a full-screen preview of each, drawn at the real geometry. The email view is the template's own 532px `height:auto` on its white ground with the promotion name below it, plus the number that matters — *"532 × 297px in the email"* — and a warning when the shape is close to square. The marketplace view is all three public boxes at their actual heights with their actual gradients. A preview that flatters the picture is worse than none: it was the flattering thumbnail that let the square logo through.
Fixed
- 🤖 "Generate with AI" works again. It had been answering *"models/imagen-4.0-generate-001 is not found for API version v1beta, or is not supported for predict."* Our key has no Imagen models at all — `ListModels` returns only Gemini image models, which answer on `generateContent` rather than the `:predict` endpoint every `imagen-*` id reaches. The button had not degraded; it had been calling an endpoint this key was never able to use. A single hard-coded model id is a feature with an expiry date — Google retires image models on its own schedule and access differs by project and billing tier — so it now walks a short list (`gemini-3.1-flash-image`, `gemini-2.5-flash-image`, `gemini-3-pro-image`), moving on only when the error actually says "no such model". Quota and auth failures stop immediately and report themselves rather than being retried three times under another name, and the failure message names what was tried. `GEMINI_IMAGE_MODEL` still wins outright when set.
- 🗂️ A generated hero was stored under a content type it might not have. Imagen only ever returned PNG, so the key was hard-coded to `.png` and `image/png`. The Gemini models return JPEG — which would have been filed under a lying name and content type and served inline from the same bucket buyers read. The bytes now decide, through the same magic-byte allowlist the manual upload uses, and anything that is not JPEG/PNG/WebP is refused rather than stored.
- 🚗 One featured vehicle no longer makes the email one car long. The featured card's photo was uncapped at `height:auto`, and dealer photos are whatever shape the phone held — a 4:3 snapshot became a 378px slab that pushed every other vehicle below the fold. It now sits in a fixed 236px wrapper that clips, with `object-fit:cover` centring the crop wherever it is honoured; Outlook's Word engine ignores both and renders the photo exactly as it did before, which is the right way for this to fail.
- 🎨 The featured card stopped fighting the rest of the email. A cream card with an amber pill sat between a Promotion badge and a CTA that are both `#253FF6` — three accent colours in one email, none of them agreeing, and the loudest attached to the thing meant to read as *chosen* rather than as a warning. The card is a light blue tint on the brand accent and FEATURED is a solid `#253FF6` pill.
Changed
- 🧩 The promotion email template lives in `packages/shared` instead of a provisioning script. It existed only inside `scripts/provision-novu-workflows.ts` and then as a stored control value in Novu, which is exactly why the app could never draw it. `buildCampaignPayload` and the hero/vehicle resolver moved beside it and the worker re-exports them, so every existing importer and its tests still point at one function. Verified byte-for-byte against what the provisioner previously emitted at the moment of the move — the send was unchanged by the refactor.
- 🖇️ An uploaded or generated hero is now stored as it arrived and goes live on every surface immediately, rather than being pushed through the cropper first. With two framings, cropping before the picture is even saved would make uploading a hero a two-step gauntlet. The shape is still visible at once — both framings are thumbnailed side by side with the inbox-height line under the email one.
Operational
- One migration, `20260916120000_promotion_hero_crops`: two nullable `TEXT` columns on `Promotion` (`heroEmailImageUrl`, `heroSourceImageUrl`). Purely additive and invisible to the code that predates it. Apply it before pushing, per the v2.9.3 order — Railway builds the worker in seconds and web-v2 in ~25 minutes, and the new worker's Prisma client names the new columns in its `SELECT`.
- No backfill, deliberately. `heroImageUrl` keeps its meaning — what the public surfaces show — so no read site changed, and the email's fallback to it *is* the behaviour every promotion had before the split. Existing promotions look and send identically until somebody re-crops one.
- ⚠️ `pnpm provision:novu` must be re-run on every environment. The in-app preview renders the template in the repo; a real send renders the copy Novu has stored, and only the provisioner updates that. Until it runs, the preview shows the new featured card and the inbox gets the old one — and a preview that disagrees with the send is worse than no preview. The catalogue stays at exactly 100 of 100; no workflow was added.
- `liquidjs` added to `packages/shared` — the same engine Novu renders with, configured the way Novu configures it: no output escaping (`bodyHtml` is markup on purpose) and Liquid truthiness, not JavaScript's, which is why the payload omits keys rather than emptying them.
- `CAMPAIGN_POSTAL_ADDRESS` is set on the worker only. Until it is set on web-v2 as well, the preview's footer shows the `Cypress, Inc.` fallback while a real send shows the real address. Cosmetic, and only inside the preview.
v2.9.4
2026-09-15💳 The number in the email is the number on the card
2.9.3 gave an invoice three ways to get smaller. This release makes the emails say so — found by testing the flow rather than reading it.
Fixed
- 💸 A payment confirmation announced the invoice, not the payment. A seller credited $60 off a $299 invoice, paid the $239 difference by card, and received *"Invoice paid — $299.00 USD … was paid successfully"*. Their card statement said $239. Nothing was ever mis-charged — `applyInvoicePayment` had the right figure the whole way and the invoice settled to exactly zero — but the confirmation read `amountCents`, the invoice's face value, while `session.amount_total` (the amount Checkout actually collected, and the same one handed to `applyInvoicePayment` eight lines above) sat unused. The ACH confirmation in the same file already reported the payment's own amount, so the two payment emails disagreed with each other depending on how you paid. It now states what was taken, and names the credit in the same sentence — *"$239.00 was collected via credit card. A credit note had already taken $60.00 off it."* — because "$239 collected" on an invoice you remember as $299 reads as a second discrepancy unless the missing money is accounted for where you are looking.
Added
- 🧾 Crediting an invoice now tells the seller. It previously notified nobody: they kept an email and a PDF stating the original amount while Billing, Stripe and Checkout had all moved on, and logging in was the only way to find out. The message leads with what is now due rather than what came off, warns that any earlier PDF or payment link shows the superseded figure, and distinguishes the two events the UI conflates — a credit note removes a charge, account credit means a balance you already held paid it. A seller told they were "credited" while their own balance quietly dropped is owed the better sentence. It reaches the same billing contacts the original invoice email did, not the whole organization.
- 📐 A lifecycle diagram of the commission invoice — `docs/diagrams/commission-invoice.lifecycle.html`. Bill, credit note, account credit, payment, settlement, centred on the identity every collecting path runs through: `outstanding = amount − credited − creditApplied − paid`, recomputed after every movement. That single test is why a part-paid invoice stays open, and why Checkout charges the difference rather than the face value.
Operational
- No migrations, no new services, no new secrets.
- No new Novu workflow, deliberately. The catalogue sits at its hard ceiling of exactly 100 of 100 — audited this release, all ACTIVE. A 101st does not error; it silently evicts an existing workflow, whose triggers then answer `{ acknowledged: true }` while sending nothing. The credit-note email therefore reuses `closing.fee-invoiced-email`, which was already almost entirely payload-driven. *(Note for anyone auditing this: the v1 endpoint reports `totalCount: 0` while all 100 exist. Only `/v2/workflows` tells the truth, and it nests under `data.workflows`.)*
- A credit note still requires a Stripe document. `invoice` is a required parameter on Stripe's credit-note API, so an invoice with no `stripeInvoiceId` cannot be credited by any path. Every invoice created from September onward has one; older ones mostly do not. `scripts/backfill-stripe-invoice-documents.ts` creates them for individual open invoices on demand — safe against live billing, since the documents are finalized with `auto_advance: false` and collect nothing and email nobody. Production was deliberately left un-backfilled; only new invoices support credit notes.
v2.9.3
2026-09-15🧾 Money that adds up, and mail that arrives
Two halves, both about a number on a screen being wrong in a way nobody inside the product could see. An invoice settled by a mix of credit note, account credit and card had three partial records and no document tying them together. And the biggest customer on the platform had stopped receiving campaigns seven weeks earlier, because a mailbox that was briefly full got treated as a mailbox that was gone.
Added — settling an invoice with something other than cash
- 💳 Account credit is a real balance now, held by Cypress. A credit note settled *carried forward* puts money on the organization's ledger (`Organization.creditBalanceCents`), and every movement is explained in `/api/billing/credit-ledger`. Deliberately not Stripe's customer balance: Stripe applies that to the next invoice it finalizes and never to the Checkout PaymentIntents we actually collect through, so credit parked there would quietly cut a future invoice's `amount_due` while Cypress went on charging full price.
- 🧮 Spending credit issues a genuine Stripe credit note. The first cut recorded the settlement on our side and let Stripe mark the invoice paid in full — making Stripe's books claim $299 arrived when $149 had. A grant cannot be a credit note when it is granted (`creditNotes.create` requires an invoice, and there isn't one yet), but at the moment it is *spent* there is. So it reconciles into a real note then, and Stripe, the bank and Cypress agree on what was collected. The invoice earns its net: a grant is a discount recognised when used, not an expense booked when granted.
- 🎁 A grant is not a credit note. Goodwill, an incentive or a negotiated rebate reduces no invoice's revenue, so it writes only to our ledger and never touches Stripe. CM-only, audited.
- 📄 The settlement statement. One page saying how an invoice came to be settled — credit notes, account credit, payments — generated on demand from the rows that are already the source of truth. A Stripe receipt only evidences the part Stripe collected; an invoice cancelled entirely by credit notes has no receipt at all.
- 💰 Invoices can be part-paid. `outstanding = amount − credited − creditApplied − paid`, in one shared function every charging, batching and settling path now goes through. Charging the full amount on an invoice that was credited or part-paid takes money that is not owed.
- 🙋 "Use your credit?" is asked before any money moves — on the Billing page *and* on the closing's FEE step, which offers Pay Now too and previously knew only an amount and a currency. A seller could be sent to Checkout to pay cash for something their own balance already covered.
Fixed — money
- 🔁 One Checkout, counted once. The old guard was the invoice's own status: whoever wrote PAID first won and the loser returned early. A partial payment leaves the invoice open, so that guard evaporated and the same Checkout would be applied by *both* the browser's verify-session call and the Stripe webhook, which race each other. An idempotency key makes a payment apply exactly once, and `InvoicePayment` keeps the history behind the total.
- 📊 Settled means nothing is outstanding, not "a payment arrived" — which was only ever true because every payment had been for the full amount. Marking the Stripe document paid out of band and releasing the closing's FEE step now happen only on full settlement.
- 🧾 "Came off the bill" and "carried forward" are separate facts. `creditAmountCents` conflated them, so a note that simply cancelled an unpaid invoice was displayed as money the organization was holding.
Fixed — marketing email
- 📮 A temporary bounce no longer exiles a customer forever. Every `email.bounced` wrote a permanent suppression, and Resend classified 827 of 870 bounces `Transient` in the preceding fifteen days — so the rule was wrong 95% of the time. A full mailbox, a greylist, a momentary 4xx removed a contact from every campaign on every seller's book, permanently and invisibly. Suppression now requires a `Permanent` verdict, a complaint, or three soft bounces across separate sends in ninety days; the remote server's own diagnostic is kept, so the CRM can say *why* an address went quiet. Complaints record as `COMPLAINED`, which is different advice from a bounce and was previously indistinguishable.
- 🚫 A spam filter can no longer unsubscribe your customers. Unsubscribe acted on a bare `GET`, and corporate mail security fetches every URL in a message before the recipient sees it — 11 of 58 unsubscribes happened within sixty seconds of delivery. The link now lands on a confirmation page; only a `POST` acts. `List-Unsubscribe` and `List-Unsubscribe-Post` headers give Gmail and Yahoo the RFC 8058 one-click path they require of bulk senders.
- 👻 We stopped mailing addresses that cannot exist. The CRM import mints placeholders — `ops@<slug>.cypress-import.local`, `historical-import@cypress.internal` — on real user and contact rows, and the notification workflows mailed them like anyone else: 1,070 sends, 1,070 bounces, 64.6% of every bounce on the account. Removing them takes the overall failure rate from 10.65% to 4.05%. Refused now at the single chokepoint every email passes through, and filtered out of audiences so an estimate never promises a recipient the send cannot have.
- ✉️ Written messages and inventory digests stop arriving as `no-reply@`. They inherited the transactional identity with no reply path, while promotion campaigns correctly carried a sender and a reply-to. One definition for both, pinned by a test — nothing in the type system connects the two files, and the drift *was* the bug.
Fixed — the three numbers a CM reads
- 👀 "Opened" counted machines. 1,293 distinct recipients recorded an open under one identical frozen `Chrome/109.0.0.0` string, while those same people's clicks came from current Chrome 142 and 152 — a mail-security fleet pre-fetching the pixel, roughly 70% of the figure. Opens are now classified and flagged, never deleted: the call is a judgement about a user-agent string, and a judgement that destroys its own evidence can never be revisited. Gmail's and Yahoo's image proxies stay counted as people — they fetch for someone who really opened.
- 📬 "Delivered" silently under-reported by 3–6%. On the 2026-09-12 send, 127 of 2,087 recipients held only their own `SENT` event while Resend's API reported those messages delivered. Not a correlation fault — every confirmation that arrived carried a provider id — the confirmations simply never came. The provider's message id is now captured from the first webhook of any kind, which is the handle that makes an unanswered send answerable.
- 🚗 "Opened a car" was already honest and stays the number to trust: it needs a real browser to render the page, which scanners do not do.
Fixed — interface
- 🔤 `−` and `…` were shipping as literal text. `'…'` inside a JS string is a real escape; the same text as JSX children or in a `placeholder="…"` attribute is not a JS string at all, so readers saw the backslash. `−$299` appeared in the Billing credit-notes table and a search box had carried a visible escape for far longer. Both fixed, and the shape is now guarded by the UI conventions suite.
Operational
- Six migrations. Five billing (`commission_invoice_credited_cents`, `org_credit_ledger`, `invoice_paid_and_credit_applied`, `invoice_payments`, `credit_note_carried_forward`) and one campaign (`campaign_machine_opens_and_provider_id`). All additive; the one backfill is exact rather than assumed.
- `mail.getcypress.ai` is verified in Resend with open and click tracking off — campaigns already track first-party, and letting Resend rewrite those URLs would add a redirect hop and double-count. Set `CAMPAIGN_FROM_EMAIL="Cypress <sales@mail.getcypress.ai>"` on the worker to move campaigns onto it; until then nothing about the sending identity changes. See `docs/email-sending-domain-split.md`.
- Enable the `credit_note.created` webhook in each Stripe account, or notes issued from the Stripe dashboard will not mirror back.
- Three scripts, none run automatically. `repair-email-suppressions.ts` clears bounce suppressions that have no surviving evidence — including the twelve from July, one of which is a live customer. `backfill-machine-opens.ts` must run with the migration, or every historical send reads zero opens. `reconcile-campaign-delivery.ts` chases missing delivery verdicts via the provider; it can only help sends made after the message id started being captured.
- Resend's own open and click counters read 0% and always did — its tracking was never applied to our mail, because the tracking CNAME was never created. No engagement was lost; the number to read is the in-platform one.
v2.9.2
2026-09-14🩹 The e-signature service was never unreachable
The Bill of Sale that started 2.9.1 still would not send for signature, and production still said **"the e-signature service was unreachable"**. It was reachable the whole time. Every failed attempt had created the signing template it needed, and then thrown it away.
Fixed
- 📮 A successful upload no longer reads as a failure. Per-transaction signing templates are created by posting the rendered PDF to DocuSeal's own upload endpoint, and the new template's id was read out of the redirect that followed — `/templates/:id/edit`. Production's DocuSeal answers that same successful upload with a redirect to `/`, so the parser found no id and threw, discarding a template DocuSeal had *just created*. Five retries left five orphans behind, each one reported to the operator as a service outage. The id now comes from the redirect when it is there and from the API when it is not — and the upload is only called failed when the API confirms nothing was created. The redirect was always a fact about DocuSeal's own UI, never a contract with us.
Operational
- No migrations. No new services, secrets, or notification workflows.
- Orphaned signing templates from the failed attempts remain in DocuSeal and can be deleted; they carry no submissions.
- The 2.9.1 release history was regenerated — `pnpm releases:generate` must run whenever `CHANGELOG.md` gains a release, and its sync test lives in `packages/shared`.
v2.9.1
2026-09-14✍️ The paperwork signs itself: one authority, one card, one honest state
A production Bill of Sale could not be forwarded to its buyer. Approving it asked the Cypress manager *"how does the buyer sign?"* — about a document generated from a template that had already answered — and answering returned **Internal server error**, five times in five minutes. Pulling that thread ran the length of the closing-document platform: who decides how paper is signed, when the buyer is allowed to hear about it, and whether the screen is telling the truth about either.
Fixed — the incident
- 🖊️ The template is the only authority on how generated paper is signed. Approval decided "generated or uploaded" from whether signature requests happened to exist, not from the document's provenance — so a generated Bill of Sale whose automatic send had failed fell into the *uploaded-document* branch, was asked a question its designer had already answered, and crashed on the answer. Provenance decides now; the e-sign/paper dialog is reserved for uploads, which is what it was built for.
- 🔌 The e-signature service login. Per-transaction signing templates are created through a scripted DocuSeal admin session, and a rejected session surfaced three requests later as an unreadable redirect. The login is now judged where it happens, and names the credentials to check. *(A hardening pass then demanded exactly HTTP 302 from a service that answers a successful sign-in with 303, reporting every healthy login as broken — pinned in tests both ways.)*
- 🧾 Zero electronic signatures is a designed state, not silence. A template signed entirely in ink used to render no signature panel at all while the step it gated waited for a scan nobody was told to make. Such documents are now born on the paper route, with the print → sign → upload instructions naming the ink signers.
- 🔀 A mixed template can finish. Seller e-signs, buyer signs the printout: the only way that document is ever completed is the ink scan — and the replace guard refused it *because* an e-signature had been collected, which is the state the ink stage always starts in. Signed copies are never refused over a collected signature, and only a submission somebody still owes is ever voided; one whose electronic parties all finished is the record that they signed.
Fixed — the screen was not telling the truth
- ⏱️ Signatures land on screen. The webhook always worked; nothing read it back. The closing polled only while a step was `IN_PROGRESS` — which multi-party signing deliberately never sets — and the panel's refetch key could not see a signature change. Fresh data sat in the response while the roster rendered *Waiting*.
- 🙈 A poll that learns nothing changes nothing. A dropped request blanked the signature panel and cleared the execution checkpoint's list of unsigned paperwork — retracting a real warning because a fetch happened to fail.
- 👻 And the panel blinked once per poll, because the card wrapping it was a component declared inside a render: a new type every time, so React discarded the subtree and rebuilt it. Now guarded by a test that fails the suite on the shape.
- 💸 "Not set — seller did not select" appeared on closings where Deal Setup *had* selected. The money selection was copied onto the step once, at generation, and skipped on regenerate-after-rejection. Approval, the payment banner and the upload form now all read through to the sheet.
- 🏦 The account picker asked the wrong organization. A Cypress manager on a seller's closing got their *own* org's bank accounts — none — while the seller's account sat on file. And where the sheet already names one, the step states it instead of asking again.
- ↩️ A rolled back approval stopped vouching for paper that is gone. Deleting a Bill of Sale under review reopened the step but left its approval timestamp, so downstream checks believed it had been approved and forwarded — and the buyer's signing link went live on a step still asking to be approved.
Added — the flow explains itself
- 🎴 Every document on a step is a card, the step's own included, with its name, whether it is required, and where its paper stands. A step that asks for two documents says so.
- 🏷️ Signature badges travel with the template — *Seller e-sign*, *Buyer ink* — on the admin hub, the org's template list, and every checklist row, so the flow impact is legible without opening the designer.
- 📋 "Filled by" means something. Upload-only, any template of a kind, or one specific template — each now explains itself, and a pinned slot stops offering to ignore its own pin (uploading a substitute survives as a recorded Cypress override). *Enforcement* split into Required? (the document) and Signature deadline (the signatures), with a warning when a required document collects signatures nothing waits for.
- 🔄 Bring a running closing up to date. Requirements are snapshotted at creation and frozen on purpose, so a checklist edited since never reached a deal in flight. A Cypress-only panel now diffs the two — added, changed field by field, removed, and kept because paper exists — and applies what you confirm. Documents whose template moved on are reported, never regenerated.
- 🤝 The three signature actions ask before they act. Emailing a link names the recipient and shows the address (editable — a typo was a silent dead end); *signed in ink instead* walks through download → print → scan; and Mark signed now records the signature on the roster, stamped as confirmed off-platform with who, when and why. It used to complete the step while the checkpoint still read *"awaiting signature"* — erroring on the very action meant to settle it.
- ✨ Review with AI, everywhere, and it reviews. One link into Sapele on every document — including checklist slots, which had no way in — carrying the last verdict in its colour. Opening an un-analysed document now runs the check instead of offering another button.
- 🧍 The buyer waits for approval. DocuSeal opens the buyer's turn the moment the seller signs; the *process* opens it at approval. Their link, email and copy stay behind that hold, enforced server-side.
- 📄 Step 3 shows the same roster as the Bill of Sale step — same document, same submission — with Sign, copy, email and per-signer status, plus *signed in ink instead*. It used to offer a link into the e-signature service's admin screen.
Security & privacy
- 🖼️ A cross-organization leak in base templates. Rendering never checked image ownership, so a logo file belonging to one organization, placed on the Cypress base template, would have printed on every organization's Bill of Sale — while the designer warned it would "render blank". Base templates now accept only the symbolic company-logo slot (refused at picker, save and publish), and rendering resolves a key only for the org that owns it.
- 🛡️ Personalizing a template forks a copy. In an organization's context the action is *Customize for this org*; editing the platform base is a separate, Cypress-only act with a blast-radius confirm.
Operational
- No migrations, no new services, no new secrets, no Novu workflows.
- DocuSeal webhook registrations de-duplicate. `WebhookUrl.url` is an encrypted column, so the setup script's `find_or_initialize_by(url:)` could never match and added a row every run — three had accumulated, delivering every signing event three times. The executed-PDF pull is now guarded against duplicate delivery too.
- Owed on production after deploy: the base *Cypress Bill of Sale* carries an unpublished draft holding one customer's logo file. Fork that customer their own copy with the logo, and restore the base — until then the leak above is latent rather than fixed in data.
v2.9.0
2026-09-11🧭 The Customer 360: Performance, Engagement, and Sakura's read on both
The platform could say what happened — sends, sales, offers — but not who anyone **is**. A company's browsing, purchases and CRM record lived on three screens that never met. This release rebuilds analytics around one spine: **Engagement** is the demand half, **Performance** is the money half, the **CRM** acts on both — and every screen carries the same **As buyers / As sellers** lenses, because one company can be hot on one side of the market and cold on the other.
Added
- 🏢 Performance is the trading book. The three aggregate lenses are gone; the page is a grid of companies with their transaction truth per direction — sellers: stock, sold, GMV, floor capture, days to sale; buyers: bought, spend, vs asking (how far under listed price they close — green downward, a buyer's discount is their win). Header KPIs, one range control, and the same three-stage "A sale is" switch as the platform dashboard, so two pages can never disagree about what a sale is.
- 📈 A per-company report, both directions. Daily closes with every sale marked, the floor-discipline bar (above / at / below floor — one average hides two different businesses), distributions by make, body, market and price band, counterparties ranked units-first, and the trading profile: offers to close, first-view → offer → close latency, price vs floor / vs asking / vs Black Book valuation, churn speed and cadence — percentages averaged per car, so one Sprinter never tells the fleet's story.
- 🌉 The offer-funnel bridge. Side by side, the two reports used to contradict — "27 won" vs "5 bought". Both were right; they read different stages. The report now shows every car under an accepted offer and where it stands — in flight, sold with DD open, completed, failed DD — with the reconciliation stated outright. Cars, never offer events, on both pages: a re-accepted offer is the same car, and the engagement chart's legend now agrees with its own KPI cards (Lost 8 was seven negotiations that later won).
- 🔭 Engagement opens at the company grain. One row per company with its people's behaviour merged honestly — distinct cars unioned across colleagues, never summed — with a Companies | People switch, because two buyers at one org want different things and outreach mails a person. Company keying follows the org join first (one colleague carries the book edge, another only the conversion; they are one company).
- 🌡️ Hot / Warm / Cold everywhere, one rule. Offer = hot, a real page view or save = warm, email opens and clicks alone = cold (that is what scanners produce). On the Customers grids, the CRM contacts and businesses lists, and the Performance grid — sortable, chip-filterable, derived per request from 30-day identity sets, never stored.
- 🧠 Sakura reads every profile — including yours. A three-sentence read at every grain: who they are, the pattern (floor discipline, velocity vs aging, concentration risk — "69% of spend rides on one seller" is named), and one next move. Cached 24h and shared by every surface — the CM record, the reports, and the org's own view read one story, so the feature costs about one generation per org per day.
- ☀️ The org 360, self-service. Today opens with Sakura's read on *your* org, linking your own engagement and performance reports — the exact pages the platform reads about you. `/analytics/engagement` is the org's own report, resolved through the session; no parameter can point it at anyone else.
- 🔗 The CRM is the third corner. Cross-links in every direction: record ⇄ engagement ⇄ performance. The record's "Looking at" tab carries the report's summary; the Deals tab has the as-buyer/as-seller lens; the People tab reads through the org join, so a converted colleague is one of the company's people whether or not the book edge was drawn.
- 📬 "Opened a car" links the car. The recipient log's strongest signal now names and links the exact car or promotion a person opened.
- 🧪 The UI conventions are a gate. The design language (page anatomy, chips, grids, relative dates, temperature palette) lives in a source-guard test that fails the suite when a screen drifts — it caught three live violations on its first run.
Fixed
- 🎯 The engagement actor rollup dropped email-carried views/saves/offers entirely and could double-count the rest; the book showed "viewed: 0" for people who had opened cars. Normalised and deduped by (person, kind, car, 5-minute bucket).
- 🧊 CRM lists sorted by heat did not sort; the parallel sort map knew nothing about new columns. One implementation now — the columns sort themselves.
- 🏷️ "Top Customers" on the platform dashboard became Top Clients — the data was selling orgs; the people browsing cars are the Engagement report's Customers, and one word must not mean two populations.
- 🪟 The business record crashed opening "Looking at" (a fetch landed in the wrong variable) and showed 0 contacts for a company whose person converted through the org.
Security & privacy
- 🔒 The counterparty rule, enforced server-side: a business org sees a counterparty's business with them and nothing beyond. A seller's customer report is scoped to their own stock, campaigns and offers; a customer's own selling performance (stock, offers received, won/lost) is platform-only; platform-wide heat rides only the caller's own row; floor prices never reach a buyer — every floor-derived figure is stripped before the response leaves the API; and every Report/CRM door is verified against the caller's own book.
Operational
- One migration, additive: the `SakuraCustomerInsight` cache table. Auto-applies on deploy; no backfill, no data changes. Verify by query, not deploy logs.
- No new services, secrets, or Novu workflows.
- The numbers prove the platform: across every completed sale on production at release time — 132 cars, $3.5M GMV, 85% closed at/above the seller's floor (avg +$767 per car), +2.9% over Black Book wholesale across 129 valued sales, median one day from listing to sale. Every figure lives on a screen a seller can open about their own business.
- Why this matters: every seller and buyer now has a legible profile — behaviour, trends and habits, per person and per company, in both directions — and it is the raw material for matchmaking (demand and supply profiles computed by the same modules, speaking the same language), recommendations ("show Dan the cars Dan actually buys" is on file, not a guess), personalised offers (campaigns aimed at profiles instead of the whole book), and Sequoia asset planning (a fleet planned against real demand). And it is not platform-only: every org's own 360 opens their Today page, fresh at login.
v2.8.8
2026-09-10🎯 Reached means we actually sent it
The Marketing screen said **4,351 people reached** for a campaign whose entire book is 2,151. Three separate mistakes were stacked inside that one number, and every rate on the screen divided by it — so every rate was wrong too. This release makes one word mean one thing, and stops a public page crashing when its own API blinks.
Fixed
- 🎯 "Reached" counted three different things at once, none of them people reached. The figure summed each send's recipient count, so a campaign that mails the same book daily grew its own audience on paper: two sends of ~2,075 read as 4,170. The per-campaign totals were then added together in the header, counting anyone in two campaigns twice. And underneath both, a recipient row with no `sentAt` — someone we listed and never wrote to, because the send was queued, cancelled or died halfway — counted as reached all the same. Three figures are now kept apart: asked (listed), dispatched (handed to the provider), and people (distinct addresses among them). *Reached* is the last of these, everywhere it appears.
- 📉 Every rate on the campaign card divided by that inflated number. The people figures were counting rows too — `COUNT(*)` on the recipient table is one row *per person per send* — so an open rate shown as 13% was nearer 26%. Rates now divide by distinct people who were actually mailed.
- 📊 One open rate, over one denominator. The campaign list divided Opened by *delivered* while the campaign's own Analytics card divided by reach, so the same campaign showed two different open rates depending on which screen you were on. `delivered` was the worse of the two: it depends on provider webhooks arriving, so it sags during any webhook lag and quietly inflates every rate built on it.
- 🖱️ A public vehicle page returned 500 when its own API blinked. Five public pages render by fetching this app's own API over HTTP, and all five guarded the JSON parse but not the request — so every failure on that path degraded politely to "not found" except a transient network error, which threw out of `generateMetadata` and crashed the page. They now share one helper that cannot throw, with an 8-second timeout in place of waiting forever.
- 🔌 Those same pages had a localhost fallback baked into the production bundle. `NEXT_PUBLIC_APP_URL` is inlined at *build* time; built without it, the server fetched the visitor's own machine — the same defect as the ARVIS `localhost` link fixed in 2.8.7. A runtime `APP_URL` now takes precedence, and production has no localhost fallback at all.
Added
- 👁️ A recipient's status can be "Opened a car", and it outranks "Clicked". The per-recipient log's strongest status was CLICKED — which any machine that fetches the tracking redirect produces — so its best-looking signal was also its least trustworthy, and the one signal that proves a *person* was not on the row at all. It now has its own timestamp column, emphasised rather than muted beside its neighbours: every other stamp on that row can come from a machine, this one means a browser rendered a real page and reported back. Available as a status filter and in the export, because "give me everyone who actually looked at a car" is the query that table exists for. Delivery failure still outranks it — a bounced address is suppressed and never tried again, which is more actionable than anything it did beforehand.
- 👁️ "Opened a car", per send and on the campaign list. The one engagement figure a link scanner cannot manufacture, because it is reported by the car or promotion page *after it rendered* in a real browser. It sits immediately before Clicked deliberately: a large Clicked beside a near-zero figure here is a security appliance walking the links, not an audience — and reading them together is the only way to see that at all.
- 📈 Every send says how it moved against the last one like it. A dispatch in isolation says almost nothing; 1,722 delivered means something only next to what the same campaign delivered last time. A fall is usually the audience eroding underneath it, since every bounce and unsubscribe is permanent per address. Comparisons are like-for-like — same trigger, tests excluded, because a test goes to one address and measuring a 2,000-recipient send against it would print +207,400%. A zero baseline gets no percentage rather than a flattering +100%.
- 🎚️ A trigger filter on the Sends panel. Tests outnumbered real dispatches fourteen to three on one campaign, burying the history anyone actually wants behind smoke checks.
- 🧭 The Marketing header reads as a funnel — who we mailed, how many of them opened a car, what it earned — and every tile now carries what its number is measured against. A bare count invites the reader to invent a denominator, which is how "4,351 reached" survived on a book of 2,151.
Operational
- No migrations. Nothing to run, nothing to back out.
- `APP_URL` is read at runtime if set; unset, the build-time `NEXT_PUBLIC_APP_URL` still works exactly as before, so no environment change is required by this release.
- Expect the reached figures to drop, and to be right. Nothing was lost; the number was overstated. Campaigns whose sends were interrupted will fall furthest, since their listed-but-never-sent recipients no longer count.
- Still open, and still not code: two campaigns mail the entire book *daily*, and "opened a car" is running at about 1%. That ratio is now measurable on every send, which is the point — but whether a full-book daily send should exist remains a deliverability decision.
v2.8.7
2026-09-10📊 Numbers that mean what they say, and the addresses you cannot reach
A 2,095-recipient campaign — the first send in this system's life to reach a real audience — took production down for twenty-five minutes and then reported its own results wrongly. This release is the remediation and the reckoning: every engagement figure the marketing screens showed was measuring something other than what its label claimed, and the suppression list the mail system had been enforcing for months was visible nowhere in the product.
Added
- 👁️ "Opened a car" — the first engagement number here that a machine cannot fake. A *click* is a hit on the tracking redirect, so anything that fetches a URL counts, and corporate scanners walk every link in a mail before it reaches a human: one send logged 1,657 clicks from 67 people, about twenty-five links each. The honest signal was half-built and could never fire — the redirect already set an attribution cookie, the engagement endpoint already read it, and the send table has carried *viewed*, *saved* and *deals sourced* columns since the engine shipped, but no event type rolled up to them. "Deals sourced: 0" was never a measurement; it was a column nothing could write. Those events exist now and are recorded by the car or promotion page itself, after it renders, in a real browser. Clicked stays on the Sends table as the diagnostic it actually is, beside its event total — the ratio between the two is how scanner traffic becomes visible at all.
- 🚫 The suppression list is visible, and it is a worklist. Bounced, unsubscribed and complained addresses have been skipped at send since the engine shipped and appeared nowhere — 380 of them in production, sitting in the CRM as ordinary contacts. An Unreachable filter beside the lifecycle chips turns them into a list you can work; each contact carries the reason and the date; the businesses grid shows how much of each company's roster is dead, red when *every* contact at a dealership bounces, which is the signal that the company has moved or closed. Deliberately derived from the suppression list rather than stored on the contact: deliverability belongs to the ADDRESS, and an active customer whose mailbox bounced is not a dormant relationship.
- 🎛️ The CRM is laid out like Fleet, line for line. Title, subtitle and the book's own numbers — total, on platform, prospects, and how many cannot be mailed — then the tabs, then a right-aligned controls line carrying Export · Group by · Filters, then the search bar and the quick chips. Filters opens a side panel rather than a row of selects, because filters combine: source, state and deliverability narrow together, and a row of dropdowns forces every one of them to occupy the screen whether or not it is in use. That is how they ended up sitting where the quick chips belong. The chips outside stay the one-click slices, now including Fleet's Recently updated alongside Unreachable.
- 🎛️ The two CRM tabs now read the same way, and the same way as Fleet. Quick chips on the left for the slices you take constantly, an accent chip on the right for the different question, and one controls line carrying the dropdown filters, the grouping and the export together — they had been scattered across three places, with Export stranded on a line of its own. Businesses had no quick chips at all and its dropdowns sat where Contacts puts its chips; it gains All / On platform / Prospect beside the same Unreachable accent chip.
- 🧮 The deal pipeline, as a list. A Board / Grid toggle on the Deals tab. The board answers "what is in play and where", which is what a pipeline is for — but a kanban is grouped by one dimension permanently and sorted by none, so it cannot answer "every deal in the Southeast, biggest first". The grid is the same records with both axes free: sort by any column, stack by stage, owner, company, region or pipeline, with the expected value summed on every heading. Grouping by stage keeps the pipeline's own order, because a pipeline sorted by bucket size is not a pipeline. The board is not replaced — dragging a card between lanes is still how a stage changes — and the filters apply to both, since a filter that narrowed only one of two views of the same records would be a trap.
- 🗺️ Region, across contacts, businesses and deals. Group and filter by market region on every CRM tab — the same five-way wholesale split the fleet already uses, so "the Southeast" means one thing across the product rather than one thing per module. Always DERIVED from the state on file and never stored, which is the rule vehicles follow: a record that moves changes region on its own and the two can never disagree. A contact carries a state of their own; a business its head office's.
- 🗂️ Contacts can be grouped — by lifecycle, owner, source or company — which the Businesses tab has always offered and Contacts never did. Grouping filters the rows you already sorted rather than re-ordering them, and "No company" sorts last however large it grows, because a list of companies that opens with the ones that have none reads as a fault.
- 🔎 A deliverability filter on both tabs. Contacts gain an Unreachable chip beside Reached and Dormant — always shown, like they are, because a filter that appears only once something is wrong cannot be looked for. Businesses gain *Has unreachable contacts* / *All contacts unreachable*; the second is the actionable one, since a dealership whose entire roster bounces has moved or closed.
- 🕓 A contact's details say when a message to them last failed, and from which campaign, whether or not the address is suppressed — a soft bounce does not suppress, so someone can be failing repeatedly and look perfectly healthy everywhere else. Matched by address rather than by contact link, because every recipient row written before this release is unlinked and a link-based lookup would report "never" for people who have been bouncing for months.
- 📊 The audience panel says what a send will actually reach — "2,151 in the book, 1,771 reachable" — instead of a number that was true about the book and false about the mail.
- 📤 Campaign recipients export to Excel or CSV, the whole current filter rather than the visible page, carrying the failure reason and suppression status per address. Formula-leading addresses are neutralised, so an exported list cannot execute anything when it is opened.
Fixed
- 🚨 A campaign took production down, and the cause was one shared row. Every tracking event — open pixel, link click, delivery webhook — incremented a counter inside its own transaction, and all 2,095 recipients of a send share one row. Each event queued on that row's write lock while holding one of ten database connections; ten stuck transactions starved the pool, every other request in the app waited up to thirty seconds, and the container was eventually killed for memory. The counter now updates outside the transaction and best-effort: it is a convenience, the recipient record is the truth, and the convenience must never fail the record. The tracking routes no longer make a visitor wait on a database write either.
- 📉 Every engagement rate was events divided by people. One send read "48% clicked" beside "3% opened" — an impossible ordering — and later "157% delivered", more confirmations than messages. Delivery webhooks retry until acknowledged and both providers are wired, so one send recorded 3,326 delivery events for 1,985 recipients. Everything a machine can repeat is now counted off the recipient row, where the first touch is stamped once. Delivered can no longer exceed Asked by construction. Every figure on both screens carries a plain-English definition of what it measures.
- 🚨 A retried webhook was recorded as a new event. With no idempotency key, the retries the outage itself provoked gave 959 of 1,984 recipients two or three delivery events each — the outage corrupting its own numbers. The provider's message id is unique now, so a retry is recognised before any write.
- 🚨 A recipient was shown as Failed on a message that was delivered. The tracking timeout was written to the recipient's failure reason; the provider confirmed delivery two and a half minutes later.
- 🔗 A campaign recipient forgot which contact it was. The column existed and the resolver always returned it; the write dropped it, so 2,108 recipients of one send were stored unlinked and no bounce could be traced to a person. That link is what makes the unreachable flag possible.
- 🖱️ "View in ORBIS" was a button wired to nothing. The vehicle side panel emitted the action from four places and no handler listened, so it did nothing at all — no navigation, no error, nothing in the console. A dead button is worse than a broken one: nobody reports it.
- 🚨 "Run in ARVIS" sent people to their own machine. The handoff read a build-time-inlined variable that came out empty in production, and its localhost fallback did exactly what it was written to do. Satellite URLs are read from the live environment now, and an unconfigured one is a legible error rather than a redirect somewhere unreachable.
- 🗓️ Saving a campaign could silently skip its next send. The editor posts the whole schedule on every save and the next occurrence was recomputed from *now*, pushing it past anything already due. It recomputes only when the cadence actually changed.
Operational
- Two migrations, both additive, applied automatically on deploy: three new values on the campaign-event enum (`VIEWED` / `SAVED` / `OFFERED`), and a nullable `providerEventId` column with a unique index for webhook idempotency. Nullable-plus-unique is safe in Postgres — many NULLs are permitted — so events the platform originates itself are unaffected. No backfill, no data changes.
- Set `ORBIS_URL` and `ARVIS_URL` on web-v2 in both environments — done. The satellite handoff reads these at runtime rather than at build.
- `scripts/repair-campaign-send-counters.ts` repairs the data the outage corrupted — the false failure and the inflated delivery counters. Dry-run by default; run it after this release is deployed, or the numbers move underneath it.
- Still open, and not code: two campaigns are set to mail the entire 2,151-contact book *daily*. Bounces and complaints compound and suppressions are permanent per address, so whether a full-book daily send should exist is a deliverability decision. Alerting on sustained 5xx and on response times approaching the pool timeout is also still to be configured — the outage produced one Sentry issue all day, from a worker noticing the app was down.
v2.8.6
2026-09-09🌲 Cedar learns the rest of the app, and express offers stop hiding
Cedar's brain had kept pace with the vehicle lifecycle; its **map of the product** had not. Fifteen releases added CRM, Mission Control, Marketing, Promotions, Storefronts, Billing and Analytics to the navigation, and not one of them touched the files that tell Cedar those places exist — so the copilot could not name them, reach them, or automate them, and nothing in the build ever objected. Alongside that, the express offers 2.8.5 made visible to sellers were still invisible to everyone else.
Added
- 🌲 Cedar can reach the whole app, and cannot silently fall behind it again. Seven of thirty-one navigation destinations had a Cedar entry. Every destination now carries an explicit decision — a scene, a set of suggestion chips, or a deliberate skip *with a stated reason* — held in one `Record` the compiler and a test both enforce. Mission Control, CRM, Marketing, Promotions, Analytics and Billing gain full scenes; eighteen more sections gain chips. The list of sections in Cedar's prompt is generated from that record rather than hand-written, which is precisely what went stale. A new parity suite reads the navigation sidebar as data and fails on any destination nobody has decided about, so the next page cannot ship invisible.
- 🎯 "What needs me?" finally means the whole platform. Mission Control already federated ten sources of pending work — offers awaiting a decision, pre-listing and onboarding gaps, stalling listings, closing SLAs, CRM follow-ups, buyer interest, archive requests — ranked the way a person triages, with a decision outranking a slipped date. Cedar could not read any of it and answered from the vehicle pipeline instead. It reads the board now, and every item carries the route where that work is actually done.
- 👥 Cedar can work the book of business. All forty-seven of its tools were vehicle-, offer- or valuation-shaped while forty-three CRM endpoints sat unreachable, so "who should I follow up with this week" got an answer about cars. Cedar can now search contacts, open one in full, list overdue follow-ups, read the deal pipeline, and propose a follow-up task — the last on the same confirmation rail as every other Cedar write, which is what makes it work by voice and on Telegram without further code. Every read is scoped to the caller's own book.
- 🔀 Automations can be built for people, not only for cars. All forty-one node types were documented, but the prompt around them was written as though only vehicles existed — so "when a contact goes dormant, create a follow-up task" produced a vehicle flow. The flow builder now names the subject first, states which nodes work on which kind of record, carries the field catalog its own conditions point at, and knows the organisation's real CRM assignees and the tags already in use. One prompt feeds the Automation Hub's generator, the canvas co-pilot and Cedar alike, so all three improved together. Contact and deal flows also get a Relationship category instead of being filed under Maintenance.
- ⚡ Express offers are legible wherever offers are. An express bid comes from a public page with just a name and an email, and telling it apart from a bid by a known dealer changes how much of the day the row deserves. That distinction lived only inside the offer panel; it now sits under the status badge in the grid, where a seller decides which rows to open. Express offers also appear in the Buyer Hub for Cypress staff, who previously saw two hubs disagreeing about how many offers exist — scoped to platform staff only, because an account-less lead belongs to no buyer organisation.
- 📊 The Seller Hub explains its own numbers. "Under Offer 17" beside "Active Offers 19" read as a contradiction: the tiles count different things — cars, and open offers on any vehicle whatever its status. Two independent things widen that gap, and only one of them is good news. The tile now states what the count is spread across, the hover gives the full reconciliation, and offers sitting on a vehicle that is *not* under offer get their own warning line, because those never moved the car's status and are missing from the bucket a seller works.
Fixed
- 🚨 Every scheduled automation Cedar built was born broken. Flows are created without an organisation of their own, and a fetch step in one blocks at runtime with "No organization selected" unless it names the organisations to read. The organisation id was never given to the model, so it could not set it — every batch or report flow the builder produced skipped its fetch and closed having done nothing. The id is in the builder's context now and the rule is stated.
- 🗣️ Voice knew fewer places than the launcher, and answered in prose. The spoken co-pilot carried its own hand-written list of seven sections — a second copy of the map that had drifted the same way — so asking for Mission Control produced a summary and no navigation. It is generated from the same source now, and given the full reachable set rather than the launcher's nine. Voice also demanded a VIN for a car it had just named by name, because it looked for vehicles only on screen and a page with no grid looked like a fleet with no cars; and five tools that return lists had no card, so their answers arrived as prose. Both fixed.
- 🧭 Picking work off Mission Control sent you to the wrong page. Each item knows where it is worked, and that route was being dropped on the way to Cedar — so asked to open the archive requests it had just announced, Cedar guessed the vehicle grid, filtered it for an archive status, found nothing, and contradicted the count it had given a sentence earlier. Items carry their destination now, and a page any board item links to can no longer be marked unreachable.
- 🚨 An automation could archive a car and leave its offers alive. Every other route out of the market — the dispose route, the delist route, the archive-request approval — goes through one helper that withdraws the open offers, cancels any in-progress closing and tells the buyers. The automation engine wrote the vehicle's status directly and skipped all of it, so a flow that archived a car left a buyer's bid sitting NEW on something that no longer existed to sell. Production held exactly one: a Nissan Versa archived by a *Platform cleanning* flow in June whose offer was still open in September. It has been closed, and automation now uses the same helper as everyone else. Delisting withdraws its offers too — pausing deliberately does not, and still does not.
- 🚨 A promotion campaign with a CRM audience emailed nobody, every day, and reported success. The audience resolver only offers CRM contacts when its caller binds a book, and it tells "no book bound" apart from "the platform book" by whether the key is present rather than by its value — so omitting it does not fall back, it removes CRM mode entirely. The CRM scheduler bound the book; the promotion scheduler did not. A promotion campaign targeting the CRM therefore resolved an empty list and materialised a send to zero people, while the audience panel — which always binds the book — showed the real number directly above it. Production's *Cypress Wholesale* campaign had been doing this daily against a book of 2,151 contacts. Two fixes: the scheduler binds the campaign owner's book like everything else, and a send that reaches nobody now records why instead of closing green with a zero — that silence is what let it run unnoticed.
- 📐 The Seller Hub says which kind of stranded offer it found. An open offer on a car that is not Under Offer means three different things: on a Paused car it is expected, because pausing keeps offers open by design; on a Live car the offer never moved the status and is missing from the bucket a seller works; on a car already sold, accepted, archived or delisted it cannot be honoured at all and should have been cancelled. One warning for all three would have cried wolf on every paused car. The last kind now draws in red and says so.
- 🖼️ Two documentation shortcuts had been rendering a generic sparkle instead of their own icons, since nothing had ever checked that a registered scene's icon exists.
Operational
- Two migrations, both additive enum values, applied automatically on deploy: a `RELATIONSHIP` category for CRM automation flows, and a `CREATE_CRM_TASK` action so Cedar can propose a follow-up on the confirmation rail. No data changes and nothing to run by hand.
- One production offer was cancelled by hand — the stranded Versa bid described above, closed with the same reason the accept path uses. No other open offer sits on an archived or sold car anywhere in production; this was the only one.
- A parity suite now guards the seams that drifted: every navigation destination has a recorded Cedar decision, every scene resolves to a real page and a real icon, the voice prompt generates its own section list, every automation node and record type is named in the builder's prompt, tools that return lists have cards, and prompt files have balanced backticks — the last because a backtick written into a prompt closes its own template literal, which cost a release earlier this month.
v2.8.5
2026-09-08📊 Sheets you can sort, offers the seller can see
A patch on the vertical 2.8.4 opened. Three small things, each of which was costing somebody real work: a spreadsheet that could not be sorted by year, an offer the seller was never told about, and a marketplace that opened as a wall of photographs when the buyer wanted a table.
Added
- 📊 Year, Make and Model are three columns, not one. Every vehicle spreadsheet this platform produces led with a glued `2022 Hyundai Tucson` cell — which cannot be sorted by year, filtered to a make, or pivoted by model, the first three things a buyer's desk does with a sheet of fifty cars. The Operating sheet and the Offer sheet now split them the way the Update template always did. One change in the shared column catalog, so all five consumers move together: the Seller Hub and Fleet grid exports, the automation `EXPORT_FILE` node, the promotion campaign attachment and the automation column picker.
- 🔗 Link columns are colour-coded by what the link is for. Red for the columns that route a buyer into a sale — Direct Link, Marketplace, and the tracked *View Vehicle* column on a promotion's attachment — and blue for the third-party Condition Report they consult on the way. The header band matches, so on a fifty-row sheet the clickable columns are findable from the header row alone rather than by reading every heading. The tone lives on the column spec, which means the browser's Excel file and the worker's are coloured by the same rule instead of two that can drift. CSV is unchanged; it cannot carry a colour any more than it can carry a hyperlink.
- 📋 The marketplace opens as a list. Both the internal marketplace and the public one defaulted to photo cards. A buyer comparing inventory reads price, mileage and condition down a table far faster than across a wall of thumbnails; Grid and Gallery stay one click away and a chosen view still persists.
Fixed
- 🚨 An express offer never moved the car to Under Offer. The offer was real — in the Offers table, in the CRM, in Mission Control — but `/api/public/offers/express` never transitioned the vehicle, and the Seller Hub's Under Offer bucket, its KPI strip and the attention queue all filter on `Vehicle.status`. So the offers 2.8.4 shipped to capture were invisible in the three places a seller actually works. The express route now makes the same `LISTED → UNDER_OFFER` transition the session route makes on its review path, guarded so a second bid does not reset the time-in-status clock.
- 🚨 An express offer rang nobody at the seller. It emailed the lead an acknowledgement and told the seller nothing — a seller heard about offers made through an account and heard silence about offers made without one. Express offers now ring the seller's Fleet Managers on the *same* `offer.new-review` / `offer.new-below-floor` workflows a session offer does, escalating below-floor bids identically. The notification says the buyer has no account and an unconfirmed mailbox: flagged, never withheld. Both routes now share one `notifyOfferForReview`, so the two doors into "a human must decide this" cannot drift apart again.
Operational
- 🗄️ No migrations, no new environment variables, no schema change.
- 📣 No `provision:novu` needed. Both review workflows already exist in the catalog; nothing was added or renamed, so the catalog stays at exactly 100 and no entry is at risk of eviction.
- 🔧 One repair owed, per environment: `scripts/repair-listed-with-open-offers.ts`. Express offers taken before this release left their vehicles LISTED. The script lists every vehicle holding an open offer while still LISTED (dry run by default) and, with `--apply`, moves them to UNDER_OFFER — backdating `statusEnteredAt` to the oldest open offer so the attention queue ages on the real number rather than on the repair. Deliberately not express-only: LISTED with an open offer is a contradiction whatever produced it.
- 🔁 Restart the worker after deploy (deploys do this; local `tsx watch` does not reload `packages/shared`).
- 💾 The marketplace view-mode preference key is versioned (`market-view-mode-v2`, `public-market-view-mode-v2`) so the new default actually reaches people already carrying the old one. Anyone who had deliberately chosen Grid will see List once and their next choice sticks.
- 🧪 Partly browser-verified before promotion. The express offer path was walked end to end locally — offer → CRM deal + contact → counter → buyer email, audited against the local Novu message log. The spreadsheet changes were verified by generating a workbook and reading the written `styles.xml`, not by opening one in Excel; the marketplace list default has not been walked in a browser.
v2.8.4
2026-09-08🌎 Regions, headline cars, and the offer that needs no account
One initiative, end to end: promote cars by region, lead the campaign with the cars you chose, and let the dealer who opens the email answer with an offer — without making them register first. The marketing email finally closes its own loop: region-targeted promotion → featured cars up top → reply or one-click express offer → a deal in the CRM, whether or not the buyer has ever logged in.
Added
- 🌎 Vehicles have market regions, automatically. The Manheim Market Report's own five-way split — West, Southwest, Midwest, Southeast, Northeast — as a shared state→region map. Region is *derived* from `locationState`, never stored, so a car that moves states changes region by definition and nothing can drift. A region filter compiles to a plain state list, which means a Filter-Criteria promotion scoped to "Southwest" maintains itself as inventory comes and goes.
- 🗺️ Filter by region or state — everywhere, and honestly counted. The marketplace, storefronts and the promotion picker all gain Region + multi-state filters (Location was a single-select state dropdown). The counts beside each option are vehicles, not states-with-inventory — "West (6)" next to a 6-row result, never "West (3)". The automation condition builder's region presets now read from the same shared map, so automations, pricing rules and the marketplace speak one region language. Sakura's search understands "in the southwest" too.
- ⭐ A promotion can feature 5–10 cars, and every surface agrees on them. An ordered `featuredVehicleIds` on the promotion itself — which is what makes it work for BOTH list modes, since a Filter-Criteria promotion has no join rows to flag. Featured cars lead the public page with a ★ badge (a visitor who explicitly re-sorts gets their order; the badge stays), open the campaign email as full-width hero cards in the order you set, and show as ranked chips on the campaign screen so what the email leads with is visible while wiring it — not discovered in the test send. Ordering lives in the shared resolver, and the public promotion endpoint now goes through it instead of around it.
- 🛠️ The promotion picker is a workbench now, not a borrowed shop. The marketplace SRP is out of the editor. In its place: a compact filter toolbar (popovers, chips, no third scrollbar), one dense sortable table — VIN on every row, click Price/Mileage/Vehicle headers to sort, page checkbox, select/deselect-all-filtered — and a sticky panel showing Featured (drag to reorder, exactly like vehicle photos) and In-promotion (searchable, badges, VIN) side by side with the results. Featured ⟹ selected is enforced everywhere it could break, including the mode switch. Same server filter language as the marketplace, so the editor can never build a query the public page won't honour.
- ⚡ Express offers: the lead in the email can just… offer. "Make an Offer" on any public vehicle page now leads with a no-account lane: name, email, amount — prefilled from the CRM when the visitor arrived through a campaign link (the tracked token already names them), anchored at asking price with −3/−5/−7% quick chips. The offer is real from the first second: same origin proofs and commission snapshot as a logged-in offer, adopted into the seller's CRM as deal + contact (+ business when they name their company), its own Mission Control section, an "Express · unverified" chip in the seller hub until the lead clicks the acknowledgement email. The lead follows everything on a private tokenized page at `/offer/…`; counters and decisions link there instead of login walls; registering with the same mailbox claims their offers into the new account. Buy Now offers the same lane one click behind the account path. Deliberately: no auction auto-accept for an unverified mailbox, and an express offer is *never* a marketing subscription.
- ✉️ Campaign mail behaves like mail from a person. Sent from `Cypress <sales@getcypress.ai>` instead of the onboarding inbox. Every send carries a Reply-To at a human mailbox — a new "Replies go to" field on the campaign, autocompleted with the author's own address, overridable to a shared inbox — and the footer says so: *"Just reply — it goes straight to …"*. Vehicle cards in the email wear the same New / Hot / $-under / Sold badges as the marketplace, from the same shared function. The campaign title is edit-in-place, and the attached vehicle sheet's on-screen table now always says it is a preview of the full spreadsheet.
- 🔎 CRM Deals are findable by the car. A search box on the Deals board (debounced, like the other tabs), and deal search now matches the deal's *vehicles* — VIN or a tail of it, make, model — plus contact first/last name, alongside the title/email/company it already knew. "The deal about THAT car" is how people actually hunt.
Fixed
- 🚨 Every promotion save silently re-marked the whole promotion as "new". The editor persisted membership by deleting every `PromotionVehicle` row and recreating them — resetting every `addedAt`, which is both the display order and what "new since the last send" is computed from. Saves now write the delta only; a car keeps the day it actually joined.
- 🚨 Applying a saved alert dropped every state but the first. Alerts store multi-state arrays; the marketplace filter was single-state, so the quick-filter chip quietly narrowed "FL, TX, GA" to "FL". Multi-state filters make the alert mean what it says.
- 🚨 Replies to campaign emails went to a mailbox nobody reads. With no Reply-To header, every reply drifted to the Novu integration's from-address (`onboarding@`). The header now rides on every send — under both Resend spellings, because the wrong one fails *silently*. Emails already delivered still reply to onboarding@; forward that address to a monitored inbox.
- 🔍 The promotion editor's picker offered no Make or Location filters at all — it mounted the marketplace component with empty facet lists. A facets endpoint (storefront-scoped for a seller's promotion) now feeds it.
Operational
- 🗄️ Three migrations, all automatic on web-v2 boot: `promotion_featured_vehicles` (ordered id array on Promotion), `offer_express_columns` (express token + email-confirm token + verified-at on Offer), `campaign_reply_to_email`.
- 📣 `pnpm provision:novu` is REQUIRED in every environment — the `promotion.campaign` template gained featured hero cards, badges and the reply footer. No workflow was added or removed: the catalog stays at exactly 100, and the express acknowledgement email deliberately reuses `consumer.confirm-account` rather than minting workflow #101.
- 🔁 Restart the worker after deploy (deploys do this; local `tsx watch` does not reload `packages/shared`).
- 📮 From-address: `CAMPAIGN_FROM_EMAIL` overrides the default `Cypress <sales@getcypress.ai>` per environment. Same Resend-verified domain, so no Resend/Novu dashboard change is needed. Transactional mail keeps the integration's own sender.
- 🚪 `/offer/` is a public route (trailing slash — bare `/offer` would have exposed the authenticated `/offers` list). It is the express lead's only door; they have no session by definition.
- 📬 Recommended, outside the repo: forward `onboarding@getcypress.ai` to a monitored inbox — campaign emails sent before this release reply there forever.
- 🧪 Not browser-verified before promotion: the express flow was walked end-to-end locally (offer → CRM deal → Mission Control → status page → confirm email); the featured hero cards rendered in a local test send after re-provisioning. Neither has been exercised on staging.
v2.8.3
2026-09-07🖋️ The rest of the paperwork, signed either way
2.8.2 fixed how the Bill of Sale gets signed. It turned out to be the only document that could be signed at all: everything else — a gate pass, a title, any slot on the checklist — refused an uploaded file outright with *"Only generated documents carry signature areas"*. A seller who uploaded their own paperwork had no route through the closing except to handle it entirely off-platform and tell nobody.
Added
- 🖋️ Any uploaded document can be signed, on any slot. The refusal is now a question with two real answers. E-sign on Cypress appends a signature page — a labelled block per party, the original pages untouched — and sends it. Signed on paper sends nothing and says what the slot is waiting for: print it, collect the ink, upload the scan. The choice is recorded on the document, so a slot never sits there looking like it is waiting on an e-signature that is never coming.
- ♻️ It reuses the machinery rather than copying it. `appendSignaturePage` already returns areas in exactly the shape `createCanvasSubmission` expects, so an upload now travels the same path as a generated document — the same signer roster, signing order, resend-by-email, webhook completion and status rows, with no new e-signature code. The split is one function: where the boxes come from. Everything after it was already generic.
- 👥 Who signs is asked, not assumed. Nothing declares the signers of an uploaded file — that is the whole difference from a generated one. The manager ticks Seller and/or Buyer, pre-ticked from the slot's pinned template where the slot has one, with each party's email shown (or "no email on file" in amber, because a party without one cannot be sent anything). Guessing here would put somebody's name on paperwork uninvited.
Fixed
- 🚨 The signing decision died the moment you acted on it. Uploading the ink-signed scan created a *new* document with no route on it — so the panel asked "how is this signed?" about the answer to that very question. A replacement now inherits the route of what it supersedes, and once the document is accepted the panel stops instructing and starts reporting: *"The ink-signed copy is on file and accepted."*
Operational
- One migration — `20260907140000_closing_document_signature_route`. Adds a nullable `ClosingDocument.signatureRoute` (`'ESIGN' | 'PAPER'`). NULL on every existing document; a document that needs no signature never acquires one. Runs automatically on web-v2 boot.
- No backfills, no new environment variables, no DocuSeal changes.
- Known seam: `signatureRoute` now exists on both `ClosingStep` (2.8.2, drives what step 3 displays) and `ClosingDocument` (this release, drives the slot). They mean different things and only overlap on the Bill of Sale; they were deliberately not converged in the same day as the first one shipped. Converging means deriving step 3's display from the BOS document's route and dropping the step column.
- Not browser-verified before promotion. The signing choice on a non-BOS slot has only been exercised by tests. Worth walking once on a gate pass or title slot.
v2.8.2
2026-09-07✍️ The paperwork, and who signs it
A Sentry alert on a field that could not be filled in turned into a week's worth of the closing's paperwork being wrong in ways nobody could correct. A custom field defined after a deal started could never be answered. A seller address fixed in the profile never reached the document. An uploaded photograph was accepted and then rejected three clicks later by DocuSeal, as a bare "Internal server error" that Sentry never saw. And the signature it did place went at a fixed spot on page one, over whatever happened to be printed there.
Fixed
- 🚨 A custom field defined after the deal started could never be answered. The deal sheet snapshots the org's custom fields when the closing is created, and `applyAmendment` refuses an unknown key by design — but Deal Setup and the generate form both offer a field the moment the org defines it. Answering one threw `"custom.seller.finalOdometerReading" is not a field on this sheet`, a 500 in production. The sheet now catches up before amending: fields defined late are seeded with their configured default, and the revision only bumps when a seeded default is non-null, because that IS a change to what the document prints. A key with no live field behind it — archived, renamed — is now a 422 that says so rather than a crash.
- 🚨 A fact corrected in the records could never reach the sheet. `refreshCoreGaps` fills only nulls, deliberately: a snapshot must not drift under a deal somebody has reviewed. But that left no way back from the ordinary case. One production deal had its seller address filled from the org profile at 21:34:32 and the profile corrected 18 seconds later at 21:34:50 — the document still prints `"Shmulo LLC 5250 W Century blvd,"` and the org's old name, and nothing in the app could fix either. The generate modal now reports what disagrees — *"2 facts changed in the records since this deal was set up"*, each as old → new — and updates only what a person clicks. Proposed, never applied: overwriting a fact under a signature is not a thing to do quietly.
- 🚨 A mistyped fact was permanent. Core facts snapshotted from a record were locked on the theory that you fix them at their source — but a fact typed into a blank has no source to go and fix. Someone typed "White" into Vehicle Stock Number on a car whose stock number is null, and it could never be corrected. Everything that *describes* the deal — colour, odometer, unit number, plate, the seller's own name and address — is now correctable on the sheet. The three that *define* it (VIN, sale price, sale date) are not, and the server refuses them too: the input was previously only hidden, and a hidden input is not a permission model.
- 🚨 A photograph of paperwork was accepted, then failed at signing. Uploads take phone photos on purpose. DocuSeal's template upload does not: handed a PNG it answers with a redirect to its own home page, which surfaced as "Internal server error" with nothing to act on. PNG and JPEG uploads are now wrapped in a one-page PDF at the door, so a photographed Bill of Sale travels the same road as a generated one. HEIC and WebP have no decoder available and stay as uploaded — fine as evidence of ink-signed paper — and the approve step names the format instead of failing blankly.
- 🚨 Six production failures Sentry never heard about. Step actions, document review and rollback each caught everything and returned a generic 500. The Next.js SDK reports *unhandled* errors, so a swallowed one is invisible by construction — the DocuSeal failure above ran six times without raising a single issue while a rethrowing route reported its first. All four now capture explicitly, tagged by route, with the same response body clients already expect.
- 🚨 A document out for signature could not be deleted at all. The refusal was protecting live signing links, which is a real concern and the wrong conclusion — the ordinary reason to withdraw paper is that it went out wrong. Deleting now voids the DocuSeal submission first, so the links die before the document leaves, and clears the dead link off the step. If voiding fails the document stays and says why: a deleted document whose signing link still works is the one outcome worse than an undeletable one. A signature already completed is still refused, and now names who signed it.
- 🚨 One Bill of Sale, two of everything. The Cypress base checklist calls the slot `bill-of-sale`; an org that wrote its own calls it `cypress-bill-of-sale`. A closing that met both carried two rows — two generate buttons, two upload boxes, one document. Slug equality could not see they were the same paper; matching `kind`, or one slot on each side of a step, can. The step's own controls also name their document now (*"Generate Bill of Sale"*), so a step that genuinely has two documents reads as two documents rather than one with duplicate buttons.
- 🚨 The step you had just worked on sank to the bottom. The hub's "Last Update" is the newest of the process and step timestamps, but a document filed on a locked or completed step touched neither — and the list was ordered by start date regardless, so the closing you were mid-way through sat among deals from months ago. The step is now touched by every upload, and the hub opens most-recently-touched first. It is a worklist, not an archive.
- 🚨 "Back" from a document review went to the wrong page. The detail panel opens without pushing a history entry, so `router.back()` returned the reviewer to wherever they were *before* the hub — a business profile, a CRM record — rather than to the deal whose document they were reading. It now goes to the closing, panel open, on the right vehicle.
- 🚨 A broken thumbnail printed the vehicle's name as damage. Update Requests and the archive queue handed the `<img>` a storage key instead of a presigned URL, and the cell's `alt` was the vehicle's own label — so the row rendered a torn-page glyph beside "2010 DODGE RAM", the name printed twice, once as breakage. Both endpoints presign now, and a load that fails falls back to the car placeholder that already existed.
Added
- ✍️ Cypress stops guessing where the buyer signs. An uploaded PDF carries no layout to read, and every one of them got a signature box at a hardcoded 10%/80% of page one — over the body text of a one-page bill of sale, and pages away from the signature block of a long one. Approving an uploaded document now asks, and offers a signature page: appended as a final page with labelled blocks at coordinates we control, the original pages untouched. Generated documents were never affected — they place fields where the designer drew them.
- 📄 The buyer can sign on paper. The seller's upload is *their* signed instrument; what is open is the buyer's countersignature, and the two routes that existed assumed either an e-signature or a document already signed by everyone. A third now sits between them: nothing is sent, and step 3 waits for the countersigned copy. That step spells out the three things to do — download the seller-signed copy, have the buyer sign it in ink, upload the scan — with the buyer able to upload it, which no step has ever allowed before. A photo is fine; it becomes a PDF on the way in.
- 🔄 A checklist change can reach a closing that is already running. Requirements are snapshotted at creation so a rule adopted mid-deal cannot retroactively block finished work — but a closing open for weeks while the paperwork changed had no way to receive the new slot either. The banner that offered this to pre-checklist closings now offers it whenever the org's list has slots this one lacks, naming them, and skipping steps that already hold paper.
- 💬 The browser's own dialogs are gone from the closing. `window.confirm` and `window.prompt` are unstyled, unbrandable, and look like the page has been hijacked — and they cannot say what a destructive action will actually do. Deleting a document now lists the consequences and, when signature requests are live, names who is holding a link and requires a tick before voiding them. Waiving a slot explains that the waiver is for this closing only. Both are the shared dialogs, so the next screen that needs one inherits the behaviour.
Operational
- One migration — `20260907120000_closing_step_signature_route`. Adds a nullable `ClosingStep.signatureRoute` (`'ESIGN' | 'PAPER'`). NULL on every existing step, which is what keeps in-flight closings behaving exactly as they did. It runs automatically on web-v2 boot.
- No backfills, no new environment variables.
- Sentry will get louder, on purpose. Four routes that were swallowing 500s now report them. Expect issues to appear that were always happening — the DocuSeal upload failure among them, until a non-PDF stops reaching it.
- Known gap: the three-way signature choice lives only in the Bill of Sale approval, the one place an upload meets e-signature today. Other signable slots go through the canvas path, where the designer declared the areas. Making it general means giving every checklist slot a signature policy, which is its own piece of work.
- Duplicate checklist rows already in the data are not removed. New closings will not get them; an existing double is waived per closing, or fixed for good by reconciling the two lists — pointing the org's list at the base slugs, or setting `kind` on both so they can never drift apart again.
v2.8.1
2026-09-04📇 A pipeline with the whole book in it
The CRM shipped after hundreds of cars had already been offered on and sold, so the board showed fifteen deals against three hundred offers — and could not have shown the rest even if they had existed, because two separate filters made the settled lanes unreachable.
Fixed
- 🚨 No deal on the board could ever reach WON. `deriveStage` checked `offerStatus === 'ACCEPTED'` across every live line item before it considered whether they were sold — and a sold vehicle's offer stays ACCEPTED forever. So every completed sale derived CLOSING, and the WON lane was unreachable for any deal that came through an accepted offer, which is every real one. The tests missed it because their sold fixtures carried no offer at all, a shape that does not occur. A sold line item is now excluded from the "still closing" read, so a fully-sold deal reaches WON while a mixed one still reports CLOSING.
- 🚨 The WON and LOST lanes could never show anything. Settled deals were excluded by stage outright — the lane rendered nothing while its header counted the whole book, so the board read "0 of 104" and no control could fix it: raising a card cap cannot surface a deal removed by a stage filter. They now age out at 90 days instead of vanishing, so recent wins stay on the board and the rest are one click away. The lane's value is the whole lane's, not the sum of the cards that fitted.
- 🚨 A deal recorded off-platform never reached the CRM. The ordinary closing path marks its line items sold and re-derives the deal's stage from `closeProcess()`; the off-platform path was missing that call entirely. So a CRM deal sat at OFFER_IN with a PENDING line item against a vehicle that had already gone SOLD — the board disagreed with the fleet. It now runs the same sync, and only when every step is attested; a partly-attested closing is not a win.
Added
- 🗂️ The deal board is built for a full book. It fetched every deal in one capped query and bucketed client-side, so one busy lane starved the rest — and a backfilled book is mostly settled deals. Each lane is now fetched and capped in its own right (25 by default), its header states the true total rather than the number that fitted ("25 of 129"), and Load more raises the cap. Settled lanes also age out at 90 days with their own count and a way to reveal them; live lanes are never hidden by age, because a stale negotiation is exactly what you need to see.
- 🎨 The board stopped being eight grey boxes. The pipeline ramp only ever tinted a 1px bar and the lane's label; everything else — columns, cards, the pipeline selector — was the same grey, so nothing on screen said where a card was in its life. Each lane now carries a faint wash of its own colour, each card a coloured left edge that survives being dragged, and the two terminal lanes leave the ramp entirely: a win is green, not "further along blue", and a loss recedes. The lane's value reads in the lane's colour rather than as another grey caption, and the selected pipeline is actually selected — it was white-on-grey against unselected grey.
- 🔗 "Leads in CRM", from wherever the car is. The CRM knew which deals were chasing a vehicle and nothing outside it said so — a seller in the hub, a buyer in theirs, a CM mid-closing had no route to them. The shared vehicle header now carries the link, so Seller Hub, Buyer Hub, Post-Sale and the admin quick panel all get it at once. It renders nothing when the car has no deals: a link to an empty board teaches people the CRM is empty.
- 🔗 A won deal is no longer a dead end. A deal's vehicle row showed the status and stopped there, with no way through to the closing that produced it. Each row now links to its closing, saying where it is — *"Closing · step 4 of 9"*, *"Closing complete"*, and *"off-platform"* where that applies.
Operational
- No migrations.
- 📇 The backfill has been RUN in production — `scripts/backfill-crm-deals.ts`. One deal per historical offer, stage derived by the same rules the board uses, dated from the offer rather than from today: a board claiming every historical deal opened this morning is worse than no board. It creates a `CrmBusiness` for buyer organisations that lack one first — only 4 of 24 had one, so most deals would otherwise have had no counterparty. `--dry-run` by default. Applied 2026-09-04: 287 deals created, 0 skipped, 0 left without a counterparty — the book is now WON 129 ($3.5M), CLOSING 117 ($3.1M), LOST 28, OFFER_IN 28, across 302 deals and 302 line items. A business whose *name* already existed but was never linked to its platform organisation is now adopted rather than duplicated: the CRM import had created those, and a second copy both duplicated the record and tripped the unique index on name. One was adopted, eighteen created.
- The board's defaults. 25 cards per lane, settled lanes aged out at 90 days. Both are raised from the lane itself; live lanes are never aged out. Worth knowing on first sight of the backfilled book: every one of the 129 wins predates the cutoff, so the WON lane opens empty and says so — "All 129 are older than 90 days" — with the reveal one click away.
- Known gap: the derived-stage logic now exists twice — `lib/crm/stage.ts` for the app and a copy inside the backfill script, which deliberately avoids dragging the app's module graph into a CLI process. A change to one has to be made in both.
v2.8.0
2026-09-04🤝 A deal closed elsewhere, and a bill that waits its turn
Two things had no home. A sale agreed outside Cypress could only be finished by pretending it happened here — the Bill of Sale step's one exit raises an e-signature request for a document signed in ink weeks ago. And the commission scheme, correctable only until the invoice exists, was billed within the hour of acceptance, before anyone had looked at the deal.
Added
- 🤝 "Closed off-platform", from Deal Setup. A CM records what happened elsewhere instead of walking nine steps that already happened. Three sections, in the order they gate each other: the commission, which is never waivable; paperwork, optional, PDF or photo, filed against the step it belongs to; and what actually happened, attested step by step. Attesting to everything closes the sale and marks the vehicle sold; leaving anything unticked keeps that step open and the closing live, with the pointer moved to the first thing still outstanding. Every attestation writes its own `ClosingStep` as COMPLETED, carrying the reason and the attesting CM — a single blanket flag would leave eight steps with no record of what was claimed about them. A modal rather than another section on the card, because this is not "what are the facts of this deal" but "this one did not happen here".
- ☑️ The step list arrives pre-ticked. The common case is a deal that ran to completion elsewhere, so the work is unticking what did not happen rather than ticking eight boxes. A partly-attested deal is a first-class outcome, not an error: untick *Title transferred* and the closing carries on from step 7.
- 💵 The commission is settled inside the off-platform flow. Recording a deal used to send a CM to Billing to pay the invoice and back to a modal they had lost. The payment — method, reference, date, optional note — is now captured in the modal's first section and goes through the same code Billing uses, so the Lago sync, the PAID transition and the FEE-step completion are identical rather than a second implementation. Billing still works for anyone who prefers it, and the modal says so.
- 🧾 An unbilled deal is raised and settled in one action. Billing waits for Deal Setup, and an off-platform deal is never confirmed by two parties — so nothing would ever raise its invoice. The flow now raises it and settles it together. Raising is what locks the commission scheme, so the modal says exactly that and points at the Deal Setup card, which is where the scheme is corrected. A raise that fails records nothing at all.
- 📎 The paperwork section shows what is actually filed. An upload used to leave nothing behind but a toast. It now lists every document on the closing — its name, the step it is filed against, whether it has been accepted — with View to open it and Replace to supersede it, the same affordances the real step gives. Five slots rather than three: Bill of Sale, Proof of payment, Gate pass, Title, and Other. Every document hangs off a step, so *Other* cannot float free — it is filed against the Bill of Sale step, and the list names the step for each file so nothing lands somewhere the reader cannot see it.
- 🚨 A fully-attested off-platform deal did not actually close. All nine steps completed, but the process stayed `IN_PROGRESS`, its pointer stuck on step 1, and the vehicle never went `SOLD` — so it reported no GMV and was invisible in the list. "What is left open" was computed from the step snapshot taken *before* the request did its work, and the inline commission payment completes the FEE step a few lines earlier. FEE is deliberately not attestable, so the snapshot counted it as outstanding forever. Completion is now decided from a re-read of the steps, which sees both the attestations and the payment. Re-submitting a deal stranded this way closes it correctly.
- 🧾 A settled commission always completes its own step. The paid path does this already; a DEFERRED or VOIDED commission never went through it, so the FEE step could stay PENDING on a deal that owes nothing — leaving the closing pointing at step 1, which the list renders as the action "Pay Invoice". Squared off when the deal is recorded.
- 🧾 An off-platform payment shows its proof on the step that blocked on it. There is no Stripe receipt for one and there cannot be: `paid_out_of_band` moves no money, so no charge exists to receipt, and recording a manual payment deliberately clears the checkout session. The proof is our own record — method, date, reference, who confirmed it. Billing already showed that; the Commission Fee step, the one that actually held the closing, did not. It does now: *"Paid off-platform by wire · 04/09/2026 · recorded by Cypress"*.
- 💤 A deferred commission no longer claims "nothing owed". A DEFERRED fee IS still owed — it is collected by the seller's ACH batch rather than now, and where the *sale* happened does not change how the *fee* is collected. The off-platform modal said "Nothing owed", which would have read as a waiver.
- 🧹 `GET /api/billing/receipts` removed. It listed *Lago* payment receipts. Nothing in the app calls it, and it had no error handling — so for the 59 of 69 organisations that still carry a `lagoCustomerId` it reached a host that no longer exists and answered 500. Receipts for Stripe-collected payments come from `stripeReceiptUrl` via `fetch-receipt`, which is what Billing actually uses.
- 🚨 "Get Invoice PDF" did nothing at all, silently. It calls `replay-fee`, which was written for Lago and keyed on `lagoInvoiceId` — so since the decommission it answered 422 for every invoice raised, and the button did not read the response. It now takes the Stripe path first: raise the invoice if it was never mirrored, settle it out of band when our own record says it is paid (Stripe never moved the money), then mirror the PDF. The Lago replay is kept for invoices that still carry a `lagoInvoiceId`. Failures are reported to the operator instead of being swallowed — including Stripe's own reason, e.g. *"Invoice number is already set on another invoice"*.
- 🚨 The test suite was issuing real invoices into the Stripe sandbox. Three real-Postgres closing tests mocked Lago but not `stripe-invoice`, so every run drove `triggerFeeStep` into a live Stripe call and burned a `CYP-…` number under the fixture org `org-fee-paywall-test`. Their database rows were then wiped, so the allocator — which reads the max sequence from our database — handed the same number to the next real deal, which Stripe refused forever. That is the whole reason a locally-closed deal could not produce an invoice PDF. Now mocked.
- 🏷️ Billing marks an off-platform sale. The status cell and the side-panel header carry the same stacked `off-platform` chip the post-sale hub uses, and the panel gains a Closed Off-Platform section — when it was recorded, why, and the reason there is no Stripe receipt to link: Stripe never moved this money, so the invoice marked paid out of band and the manual payment record are the proof.
- 🔁 A Stripe invoice we created but failed to record is now adopted, not orphaned. Stripe refuses a number it already holds. If *we* created that invoice on an earlier attempt and crashed before saving its id, every retry re-collided and the commission became permanently un-mirrorable. The mirror now looks the number up and adopts the invoice only when its metadata names this same commission — anything else belongs to another deal and attaching it would be worse than failing.
- 🗣️ "Retry PDF" says what went wrong. All four call sites fired and forgot, so a refusal refreshed the screen and looked like success. They now share one helper that reports the result. And a duplicate invoice number no longer surfaces Stripe's own wording — *"Each invoice requires a unique number"* does not tell an operator which of their systems already took it; it now says the number is held by another invoice in the account, most likely another environment sharing it.
- 👁️ A failed Stripe mirror stops looking like one nobody tried. `createStripeInvoiceForCommission` is deliberately never fatal — a closing must not be held back by a billing API — but its reason vanished into a server log, so a commission with no Stripe document was indistinguishable from one that had never been attempted. It now returns the outcome (its two fire-and-forget callers are unaffected) and marks the invoice `invoicePdfStatus: FAILED`.
- 🩹 The screen stopped describing the old billing model. With the fee now waiting for Deal Setup, three places still spoke as though it fired at closing creation: step 1 read *"Invoice generating…"* with a Regenerate Invoice button — on a deal where nothing is generating and there is nothing to regenerate; the grid's action read "Pay Invoice", pointing at an invoice that does not exist; and the step's own description said the commission is issued *"when the deal opens"*. They now read "Waiting for Deal Setup — the commission bills when both sides confirm", Deal Setup, and "once both sides confirm Deal Setup". Regenerate is still there for a genuine generation failure, which is what it was for.
- 🏷️ An off-platform deal is marked as one in the post-sale list. It read identically to every other closing — same "Closed", same step count — while being exactly the row an operator goes looking for. The Step column now carries an `off-platform` chip beside the status.
- 🔒 Deal Setup stops asking for confirmations a closed-off-platform deal cannot give. The card still offered *Confirm on the seller's behalf* and *Confirm on the buyer's behalf* after the deal was recorded — which would have stamped a sign-off, on the record, for parties who never used the platform. Both sides now explain that instead of offering the button, the header stops describing itself as a gate ("the details below are the record, not a gate"), and each side reads Not applicable rather than a spinning *In progress* that implies work outstanding. Refused on the server too, not only hidden — a 409, so the button being gone is not the only thing standing between a stale tab and a false confirmation.
- 🗑️ A wrong document can be removed, not only replaced. Replace supersedes; deleting is what you want when the file should not be there at all. The existing delete refuses an ACCEPTED document — a second party reviewed and relied on it — but a CM's off-platform filing is accepted on arrival precisely because there is no second party, which left a wrong file permanently attached. A CM may now remove one on a closing recorded as off-platform; an ordinary closing still refuses, and rollback remains the path there. Two clicks, no dialog on top of a dialog.
- 📎 The paperwork uploads actually work. An off-platform closing's steps go straight from LOCKED to COMPLETED, and the upload route refused both — so "attach the Bill of Sale" in the modal could never work in either order: upload first and the step is locked, attest first and it is complete. A locked step is still the right refusal for a seller or a buyer, who genuinely have not reached it; a CM filing paper for a deal that already happened is not a process violation. And because there is no second party to review it, a CM's off-platform upload is accepted on arrival rather than parked PENDING waiting on a review nobody will do — including anything uploaded before attestation, which the attestation now accepts alongside the steps.
- 🚫 A part payment is refused. The fee is owed in full before a deal is recorded as closed; a partial payment is stored against the invoice but the closing does not move.
- 🧾 The commission is shown in Deal Setup, and says whether it can still be changed. Confirming Deal Setup is what bills it, so this is the last screen on which the scheme can be corrected without a credit note — and it now says exactly that, rather than leaving a CM to discover the 409 afterwards.
- 🏷️ An "Awaiting Deal Setup" badge on the post-sale list. An unconfirmed sheet now means an unbilled sale as well as a locked Bill of Sale, and the step number alone read as ordinary early progress. There was no Novu workflow slot left to notify about it — the catalog is at its 100-workflow cap — so the list itself carries it.
- 🤝 "Already signed off-platform" on the Bill of Sale step. The narrower case: the whole deal runs here, but this one document was signed in ink. Accepts the uploaded BoS as final — no DocuSeal submission, no signature request to the buyer, buyer-signature step closes with it, closing advances to payment. Deliberately separate from `approve_bos`, whose entire job is to forward to the buyer.
- ⏳ The commission bills when Deal Setup is confirmed, not at offer acceptance. The scheme is snapshotted onto the Offer at acceptance and is only correctable until the invoice exists — after that the route answers 409 and sends you to a credit note. The FEE step auto-fired from closing creation on a 1-hour SLA, so that window shut before anyone had reviewed the deal and every real correction became a credit note. It now fires when the second side confirms Deal Setup: the point at which a human has actually looked, and exactly where a wrong scheme gets noticed. A closing with no data sheet has no such moment and still bills at creation — otherwise a failed sheet write would mean a sale that never bills at all. `triggerFeeStep` is idempotent on both the step and the invoice, so a re-confirm, a retry, or a CM who force-billed through `regenerate-fee` cannot bill twice.
- 👁️ The closing says which of its two gates is holding it. Step 2 waits on the fee and on both sides confirming Deal Setup, but only the fee was visible — so a paid closing showed "Commission Fee ✓" above a locked Bill of Sale with no reason. It now names which side has yet to confirm, and reminds a CM they may confirm either on the parties' behalf.
Fixed — billing after Lago
- 🚨 Credit notes could not be issued on any modern invoice. The route refused anything without a `lagoInvoiceId` — and Lago was decommissioned in v2.5.1, so that is 144 of 175 production invoices and every one raised since. Meanwhile the product kept telling CMs that correcting a billed commission was "issue a credit note and rebill", a path that had quietly stopped existing. Nobody had hit it because no credit note has ever been issued. They now go to Stripe, the accounting system of record. Our invoices settle `paid_out_of_band`, so Stripe holds no charge to refund: a refund is declared as `out_of_band_amount` ("this was refunded elsewhere") and a credit goes to the customer balance as `credit_amount` — asking Stripe for a `refund_amount` on such an invoice is rejected, which is the mistake a literal port would have made. A Lago-era invoice now says it was issued under a decommissioned system rather than "No Lago invoice linked", and a credit note larger than its invoice is refused.
- 🔇 A dead Lago call on the manual-payment path. Guarded only by "the invoice has a `lagoInvoiceId`", which 31 production invoices still do — so it reached a host that no longer exists, threw `LAGO_API_URL environment variable is required`, and was swallowed into a warning. Gated on `isLagoEnabled()` like every other call site.
- 🧹 The Lago webhook route (`POST /api/webhooks/lago`, 336 lines). The six Lago services were deleted in v2.5.1; nothing can call it.
- 🧹 Six Lago client functions with no callers — `createSubscription`, `voidInvoice`, `getCustomerUsage`, `getCreditNote`, `listCreditNotes`, `verifyWebhookSignature`. `packages/shared/src/lago.ts` drops from 578 to 474 lines. What remains serves the 31 legacy invoices that still carry a Lago id.
Operational
- 📖 Platform Manual updated. A new chapter, [Deals closed off-platform](/docs/manual/closing/off-platform) — where it lives, the three things it asks for and why they are in that order, what happens afterwards, and its business rules. *The 9-step closing* points at it and gains a rewritten Deal Setup section explaining that confirming it is what bills the commission; *Payment & commission* explains the new billing moment and that a fee paid by wire has no Stripe receipt and cannot; *Billing* explains that credit notes are issued against the Stripe invoice, and that a pre-cutover invoice has no Stripe document to credit.
- Two migrations — `20260904120000_closing_off_platform` adds `ClosingProcess.closedOffPlatform` (defaulted false), `offPlatformReason`, `offPlatformBy`, `offPlatformAt`, plus a partial index on the flag. `20260904150000_credit_note_stripe` adds `CreditNote.stripeCreditNoteId` and `stripeCreditNotePdf`. Both additive: every existing closing reads as on-platform, and the Lago credit-note columns stay so the ones issued under it remain readable.
- Revenue timing changes. Invoices are raised later than before — on Deal Setup confirmation rather than acceptance. A deal nobody confirms is a deal nobody bills, which is why the badge exists; `regenerate-fee` (CM-only) remains the force-bill escape hatch.
- Off-platform sales count in GMV. The vehicle still ends SOLD with `saleCompletedAt` and `finalSalePrice`, which is what analytics reads, so an off-platform sale reports like any other. `closedOffPlatform` is exposed on the closing detail and the list and is indexed, so a report *can* split on it — no analytics view does so yet.
- The commission is not waivable off-platform. The endpoint will not record a deal until the fee is settled — it accepts the payment inline, raising the invoice first when there is none, and refuses a partial. "Closed elsewhere" must not become the route around the fee.
- An off-platform closing no longer nags for Deal Setup. Nobody is waiting on two parties who never used the platform, so those closings drop out of the "Awaiting Setup" filter and the step-1 banner.
- Found while investigating: of ten in-progress production closings, six had already paid the commission ($299 ×4, $1 ×2) and were held only by an unconfirmed Deal Setup; three were awaiting payment; one had both sides confirmed and had moved on normally.
- Unchanged: document upload already accepted `application/pdf` (with JPEG/PNG/HEIC/WebP, 20 MB) on every step, so the Bill of Sale and gate pass could always be PDFs.
v2.7.2
2026-09-04🔒 A rule cannot un-sell a car
An offer arrived on five vehicles and the platform recorded it correctly. The next morning, lowering their listing price re-ran a listing rule over all five and quietly put them back on the market with the offers still open. Two independent faults had to line up: a trigger that fired on a field nobody had changed, and an action that would overwrite anything.
Fixed
- 🚨 An automation flow could revert a vehicle that was under offer. The `CHANGE_STATUS` node wrote the status it was configured with and consulted nothing — not the vehicle's current state, not the transition table the rest of the app is bound by. So the GLOBAL flow *General Listing Price Rule* ("when the floor price changes: set listing price to floor + $800 → Approved → Listed"), fired by an ordinary floor-price edit, walked five `UNDER_OFFER` vehicles back to `LISTED` while $14,500 offers sat unresolved in the review queue. `UNDER_OFFER → APPROVED` is not a move the UI offers anyone — automation was simply never checked against the same rules. Automation may now never move a vehicle out of Under Offer, Offer Accepted, Due Diligence, Ready to Deliver or Sold (Sold → Archived stays open, which the bulk-archive flow depends on), and never into Under Offer, Offer Accepted or Sold — those are recorded by the offer and closing routes that create the commitment behind them, along with the offer, closing process and commission a badge alone would fabricate. A blocked node stops its branch and says why in the run history, the same way the existing floor-price gate does.
- 🚨 The same flow overwrote a listing price a live offer had been made against. `SET_LISTING_PRICE` recomputed floor + $800 and wrote it over the $15,200 the operator had set seconds earlier, pushing the standing $14,500 offer back below floor. `SET_LISTING_PRICE`, `SET_FLOOR_PRICE` and `APPLY_PRICING_RULE` now refuse to move pricing on a commercially committed vehicle, for the same reason and over the same status set.
- 🛡️ The status guard is enforced again at the write, not only in the node. A flow evaluates against a snapshot taken when the run began; an offer landing mid-run would have slipped past a check made only in the handler. `automation-db.changeStatus` re-reads the vehicle's current status and refuses there too.
- ↩️ A no-op status change is a pass-through, not a write. A flow told to set the status a vehicle already has now continues down its branch without touching the row or logging a change.
Added
- 👁️ A blocked automation step now says so on the vehicle's own Activity tab. A step that declines to act was recorded on the flow run and nowhere else, and the run log is not where anyone looks when a car is in the wrong state — the vehicle's timeline is, which is exactly where this incident was diagnosed. A block writes an `automation.blocked` entry there, carrying the flow name, the step that declined and the reason, alongside the `automation.status.changed` and `automation.listing_price.set` entries that record what a flow *did* do. Activity only, no Updates-feed notice: a flow declining to act is operator context, not news for the seller. Never on a dry run — nothing was declined there, only simulated — and best-effort, so a timeline write can never fail a run that has already correctly refused to act.
- 🔔 The flow's author is told when a run does less than it looks like it did. *(Code shipped, delivery not yet enabled — see Operational: the Novu workflow cap blocks it.)* A `PARTIAL` run — one where some step declined — was visible only to someone who opened the runs grid and looked. That is how a rule reverting five vehicles under offer went unnoticed for ten hours. The author now gets a notification naming the flow and what was declined. A GLOBAL flow carries no organisation of its own, so the notice is attributed to the org of the record the run acted on.
- 🚨 A vehicle update announced every field the form posted as "changed". `ENTITY_UPDATED` reported `Object.keys(updateData)` — what was *submitted*, not what *moved* — and a flow's `watchFields` is matched against that list. The vehicle form posts its whole price section on every save, so lowering only the listing price still announced `floorPrice`, and the flow watching `floorPrice` fired on a floor price that was $15,200 before the save and $15,200 after it. This is the trigger half of the incident: without it the rule would never have run at all. Changed fields are now diffed by value (`changedVehicleFields`), the same way the `DETAILS_UPDATED` notice beside it already did and the CRM side has always done. A save that moves nothing now enqueues no run, where before it fired every flow watching any field.
Operational
- No migrations.
- No Novu provisioning needed. The blocked-run notification was going to add a `automation-run-blocked` workflow, but the account's plan caps at 100 workflows and the catalog was already exactly at the cap. Provisioning deletes every workflow and recreates the catalog in order, so the 101st entry does not fail on itself — it fails on whichever is last, and the first staging run silently evicted `reminder-dd-deadline`. That would have killed DD deadline reminders in production. The entry is out of the catalog again (staging re-provisioned clean at 100/100, `reminder-dd-deadline` restored) and the catalog is byte-identical to what production already runs. The worker still enqueues the notification and the processor drops an unprovisioned workflow with a warning, so it is inert, not broken. The Activity-tab entry is unaffected and is the one that matters — it needs no Novu at all. Turning the notification on needs a raised workflow limit or a retired workflow, then the catalog entry back and a re-provision per environment.
- Behaviour change worth knowing: a flow with an empty `watchFields` ("any field") used to fire on every vehicle save, including one that changed nothing. It now fires only when a value actually moved.
- Data repair — `scripts/repair-automation-clobbered-offers.ts`. Restores vehicles automation knocked out of `UNDER_OFFER` while their offers were open, setting `statusEnteredAt` to when the offer actually arrived rather than to now, and writing an audit row naming the repair. Selection is derived, never hardcoded: a vehicle qualifies only if it has an open offer, is not currently `UNDER_OFFER`, and carries an `automation.status.changed` row leaving `UNDER_OFFER` dated after that offer. Anything else with an open offer is reported and left alone. `--dry-run` by default; `--restore-price` additionally undoes the listing price the flow overwrote, read back from the audit trail. Production dry run: 5 to repair, 2 left alone (a Malibu and a Versa whose statuses drifted for unrelated reasons and need a human).
- Known gap: nothing validates a flow at publish time, so a flow can still be *authored* with a node that will be blocked at run time. It fails safely and says why in the run history, but the editor could say so earlier.
- Not changed: no flow definition was edited. *General Listing Price Rule* is GLOBAL (every organisation) and still re-prices and re-publishes any vehicle whose floor price changes — which is what it was written to do, and is now harmless to vehicles under offer. Narrowing its scope, or adding the existing `HAS_OPEN_OFFER` condition to it, is a product decision left to its owner.
v2.7.1
2026-09-03📣 A promotion that says what you want it to say
A filter-criteria promotion turned out to be invisible to everything except its own page: the campaign screen called it empty, the offer sheet built no rows, and the email carried no cars. Fixing that exposed the next thing — a promotion email had no words of its own to change — so it has them now, and the link box stopped demanding that anyone know what a URI scheme is.
Added
- ✍️ A promotion campaign can say it in your own words. Under *Carries: a promotion*, a Message choice: the promotion's own copy, or your own words. Pick your own and the full designer opens — subject, rich text, personalisation chips, templates, Ask Cedar — and what you write replaces the prose above the vehicle cards. The hero image, the cars, the tracked call to action and the offer sheet all still ride along; only the opening changes. So the same promotion can go out daily without going out saying the same sentence daily, which was previously only editable on the promotion itself, where every recipient shares it. Both options show what they will actually send: "the promotion's own copy" prints the subject it generates and the tagline it will use, because naming a default without showing it is not a readable choice. Left blank, a campaign falls back to the promotion's own copy rather than mailing a gap — the email is complete either way, so it is not a reason to block activation.
- 📞 A link box for people who do not write URIs. It asks what *kind* of thing you are linking — web, email, phone, or the promotion — and builds the scheme itself. Type `(918) 688-6731` and get a working phone link; the word `tel` never appears. Each kind names its field for what it holds and says what the link does when someone taps it. Use my own address / Use my own number fills in the sender's own details in one click, which is what a signature wants and what nobody would guess they could type. A bare `example.com` becomes absolute, because a relative link in an email resolves against the *mail client*. Problems are named while you type, in the terms of the kind you chose, rather than by the validator at save time. Editing a link reopens on the kind it already is, showing the number you typed instead of the URI that was stored, and Remove link is a button rather than something you discover by emptying the box.
- ☎️ `tel:` and `sms:` links are allowed in a message. A phone number in a signature is the whole reason the case exists.
Fixed
- 🚨 A filter-criteria promotion was treated as empty everywhere but its own page. `DYNAMIC_QUERY` promotions keep no `PromotionVehicle` rows — resolving their saved filters against live inventory every time they are read is what makes them dynamic — and every consumer outside the public promotion page read that join table directly. So a promotion built to mail the whole inventory reported 0 vehicles on the campaign screen, attached an offer sheet with no rows (and the sender, seeing an empty sheet, quietly mailed the campaign without it), sent an email with no vehicle cards, matched nobody in a segment audience, and reported analytics about no cars. One resolver now answers "which cars is this promotion about" for both list modes, and every consumer goes through it: the offer sheet and its preview, the promotion picker and the campaign screen, the audience builder, the test send, the scheduler and the sender, analytics scope, the promotion Sankey, suggested audiences, and the admin list. Verified against a real promotion that had been showing zero: 481 vehicles.
- 🚨 No promotion campaign could be saved or activated. The editor posts an explicit whitelist of fields on every save, `bodyDocument` among them — and a campaign carrying a promotion never opens the message designer, so it posts `null`. The route validated that null and answered every such save `422 document: expected an object`, printed verbatim under the send panel. Null now means "no rich body", while a document that is present and malformed is refused exactly as before.
- 📅 Every promotion email for a promotion with no end date carried a dangling "Available until ". Liquid's truthiness is not JavaScript's — only `nil` and `false` are falsy — so the empty string passed the template's own guard and printed a label with no date after it.
- 📧 A vehicle-card cap, now that a promotion can resolve to the whole fleet. The one that prompted this resolves to 481 cars, and a card each meant 481 presigned image URLs in one payload, per recipient. The email shows the first twelve and says how many more are waiting; the call to action already leads to the page that lists every one. The spreadsheet stays uncapped — carrying the full list is its job.
- 🔗 The editor could insert links the save would then refuse. TipTap's link extension accepts `ftp`, `ftps`, `callto`, `cid` and `xmpp` on top of what we store, and its `protocols` option can only add to that list. That mismatch is how a phone number produced `document.blocks[8].children[2]: link must be http(s), mailto:, or a placeholder` — a JSON path, about a scheme, shown to someone adding a number to their signature. The designer now validates through the same predicate storage uses, so the toolbar cannot offer what the save rejects.
- 🖇️ Linking text that contained a personalisation chip silently dropped the link. The editor-to-storage translation pulled placeholder nodes out *before* reading the link mark, so "Call {{Your name}}" linked to a number saved as a link around "Call " with the chip fallen outside it — with the editor looking unchanged until the next reload.
- 🖱️ Clicking Link with no text selected did nothing at all. A link is a mark on a range, and an empty range has nothing to mark, so the dialog accepted an address and the editor swallowed it. It now inserts the address as its own linked text, which is what the signature case wanted anyway.
- 📄 The same address stopped appearing twice in the plain-text part. A signature read `apetrillo@grupoautofin.com (mailto:apetrillo@grupoautofin.com) · (918) 688-6731 (tel:(918)%20688-6731)` — each value twice, the second time in a form nobody dials. A `mailto:`/`tel:`/`sms:` destination is now spelled out as the decoded address, and omitted when the link text already shows it. Matched loosely enough that a `+1` on the href and a local number in the text count as one number, by suffix rather than by guessing country codes. Web addresses are still spelled out, because there the URL is what the reader needs.
Security
- 🔐 A resolved placeholder can no longer put a dangerous scheme into an anchor. A placeholder-led href passes the storage allowlist — there is nothing to check until it resolves — and then becomes whatever the CRM record holds at send time. HTML-escaping does not help: an entity-encoded `javascript:` URI still runs. The scheme is now re-checked on the resolved value at the point markup is written, and a link that fails renders as its own text instead of an anchor. It needed control of both a template and a contact record, and mail clients block `javascript:` regardless — but the allowlist had stated this guarantee and was not keeping it.
Operational
- One migration, auto-run on deploy — `promotion_campaign_custom_message` adds `PromotionCampaign.useCustomMessage`, a boolean defaulting false. Additive: every campaign that already exists keeps rendering from its promotion exactly as it does today. The choice is a flag rather than "is a body set" so that switching back to the promotion's own copy keeps the draft, and so "meant to write something" stays distinguishable from "never wanted to".
- 🔔 Re-provision Novu per environment (`pnpm provision:novu`). The `promotion.campaign` email template changed — a block for the author's prose, which suppresses the tagline when present, and the "and N more" line. Without it the payload carries the new fields and the template ignores them: campaigns keep sending, but a custom message will not appear. Verified locally by rendering the live template with liquidjs, the engine Novu itself uses.
- `storefrontVehicleWhere` moved to shared (`channels/storefront-where`) so the worker can scope a seller-owned dynamic promotion to the same storefront the catalogue serves. The old path is a pure re-export.
- The per-recipient placeholder context is now one function shared by both senders (`campaign-copy-context.ts`). A test send goes through one path and a real send through the other; they have to personalise identically.
- Known gap: the *Message* choice on the campaign screen has not been exercised in the real app — it is covered by unit and component tests, and the send path is verified end to end against a real promotion through the live Novu template, but nobody has clicked it on a live campaign.
- Known gap: validation errors in the message designer still surface the raw path (`document.blocks[8].children[2]: …`). The scheme cause is now unreachable from the editor, but another cause — an unknown placeholder, say — would still show a JSON path to an author.
- Not touched: the legacy `/api/admin/promotions/[id]/campaign/{estimate,test,audience-preview}` routes still read the join table directly. Nothing in the app calls them.
v2.7.0
2026-09-03📨 Messages you can reuse, sheets you can send
Everything in this release is about the moment a spreadsheet or an email leaves the building. Three exports where there was one, a promotion that can carry its own vehicle list, delivery reporting that lands on the right recipient, and — new — a library of reusable messages with a rich-text designer.
Added
- 📑 Three export sheets, not one. Every vehicle export now asks which sheet you want. Operating is the desk's own view — floor price, list price, condition, activity. Offer sheet is safe to send a buyer: what the car is, what it costs, where to click, and no floor price anywhere. Update template carries the headers the import wizard recognises, so export → edit in Excel → re-import by VIN finally closes. One definition in shared feeds the Fleet grid, the Seller Hub grid and the automation Export File node, so a file a seller downloads and a file a flow mails cannot label the same column two different ways.
- 🎨 The spreadsheet looks like one. Bold white-on-slate header band, right-aligned over numeric columns, currency and thousands formats that keep the values numeric so `SUM` still works, bounded column widths so a URL column no longer pushes everything off the screen, and an autofilter row. Links are real, blue, underlined hyperlinks — clickable in Excel while the address stays copyable out of the cell.
- 🚗 New columns: Colour and Stock # (neither had ever been exportable), Location Zip, and the Manheim condition report link. The Marketplace link column joins Direct Link, each naming exactly one sales channel — because the door a buyer arrives through decides the commission scheme, and a seller reading the sheet needs to see which fee a row will bill at. A menu control says how many rows each link column would actually fill, so a sparse column is explained before the download rather than puzzled over after it.
- 📎 A promotion can attach its vehicles. Tick one box and the campaign email carries an Excel offer sheet of the promotion's cars, and the campaign screen previews the real rows — built by the same function that builds the mailed file, so the preview cannot misrepresent the attachment.
- ✉️ Message templates. A library of reusable emails with a rich-text designer: headings, bold, italic, links, lists, and personalisation as atomic chips you cannot break by formatting. Cypress seeds a base library every organisation can send as-is; an organisation copies one to make it theirs. Draft and publish, version history with restore, duplicate, export and import, and a category badge. The same designer edits a campaign's written message, so a message written in a campaign and one saved to the library are the same artifact with the same rules. Use a template opens a filterable picker with a live preview beside it, and inserting takes a copy — the campaign owns the words from that moment, so editing a template can never rewrite a campaign already scheduled.
Fixed
- 🚨 A campaign switched to "a written message" kept mailing its promotion. The send worker forked on `kind` — which the database ties to whether a promotion is attached — instead of on `contentKind`, which is what the editor's *Carries* control sets. So the screen said one thing and the inbox got another, with the written message thrown away. Routing is now on what the campaign carries, and a written message may legitimately carry a promotion: your own words, with `{{promotion.name}}` and a tracked promotion link resolving inside them.
- 🔗 The editor offered promotion placeholders the send could not fill. The written-message renderer had no promotion in scope, so *Promotion name* and *Promotion link* always fell back to their default wording even when a promotion was attached.
- 📬 Delivery events land on the right recipient. Resend's webhook carries no recipient id, so deliveries were matched to "the most recent recipient with that address" — which mis-assigns as soon as one address appears in two sends. Each email now carries its recipient id as a Resend tag, echoed back on the delivery event; the address heuristic survives only as a fallback for mail already in flight.
- 📈 A campaign reported "300% clicked". Engagement rates divided by *delivered*, and delivery confirmations arrive over a webhook that lags — or, on a developer's machine, never arrives at all. Rates are now over reached, the one denominator the screen always knows, with delivery reported as its own line so its absence is visible instead of silently distorting two other numbers.
- 📉 A funnel stage with an empty predecessor claimed 0%. "Three people clicked, none of whom opened" has no rate; reporting it as 0% asserts nobody advanced in the same row that counts three who did. It now reads "—". The fix covers the campaign report, the Seller Hub vehicle panel and the CRM deal panel, which all render from the same builder.
- 🚫 The audience estimate explains its own arithmetic. "3 valid recipients" over "Estimated recipients: ~2" had nothing to account for the gap. The suppressed count was always computed and always discarded; the screen now names it — bounced, unsubscribed or marked as spam — and says why mailing them again would damage delivery for everyone else.
- 📭 An address the mail provider refused was reported as sent. Resend accepts the API call for an address on its own suppression list, never delivers, and says so with `email.suppressed` — an event the webhook did not map, alongside `email.failed`. Nothing ever contradicted the SENT written at dispatch, so the audit reported a send that provably did not happen. Both now record a failure with the provider's reason, and a suppression also adds the address to our own list: one Resend will not deliver to must stop being counted as reachable, or every future estimate promises a recipient who cannot receive. The two events are kept apart on purpose — a suppression is permanent and about the *address*, while a failure (quota, unverified domain, bad key) is transient and about *us*, and must never cost us a reachable person.
- 🧭 The sidebar highlighted two entries at once. Nav links prefix-match, so `/marketing` stayed lit on every `/marketing/*` page. Both collisions are excluded.
Operational
- Three migrations, auto-run on deploy — `campaign_attach_vehicle_sheet`, `message_templates` (the template pair plus a campaign's rich body, additive so existing plain-text campaigns are untouched), and `message_template_category`.
- The automation Export File default no longer includes Floor Price. That node feeds Send Email, which mails to arbitrary off-platform addresses, so a flow author who never opened the column picker was mailing the number the seller would accept. Flows that explicitly ticked Floor Price keep it; flows that never chose now get the offer sheet. This changes the output of existing untouched flows, deliberately.
- No Novu changes. Rich message bodies render through the existing `campaign.message` template, verified end to end against a real send.
- New dependencies in web-v2: `xlsx-js-style` (SheetJS Community parses cell styles and then discards them on write, so the header band and hyperlink styling were never reaching the file) and TipTap for the message designer.
- Known gap: the message library ships empty — no Cypress base messages are seeded yet.
- Known gap: links inside a written message are not click-tracked. Only a promotion's vehicle links and its call to action go through the tracked redirect, so a link an author types into the body reaches its destination without being counted. Closing it needs the click endpoint to accept an arbitrary destination, which is an open-redirect surface and wants a signed target rather than a quick parameter.
- Subscribe the Resend webhook to `email.suppressed` and `email.failed` in each environment, alongside `delivered`, `bounced` and `complained`. Without them a refused send still shows as sent.
- The Platform Manual updates Marketing campaigns and Selling: launching.
v2.6.2
2026-09-02✍️ One-off CRM outreach sends again
An audit of the three Novu environments after 2.6.1 found `crm.outreach` sitting in **ERROR** in all of them — local, staging and production alike.
Fixed
- ✉️ "Email this contact" was silently sending nothing, everywhere. The template signed off with `{{senderName}}{{#if senderOrgName}} · {{senderOrgName}}{{/if}}`, and Novu's control-value validator rejects Handlebars block helpers outright (`ILLEGAL_VARIABLE_IN_CONTROL_VALUE`). The workflow was left in ERROR, its email step failed bridge execution, and one-off outreach from the CRM reached nobody — with nothing on screen to say so. The separator is now computed where a condition is allowed, at the call site, and a caller without an org degrades to the bare name instead of a dangling separator. Verified end to end: the workflow reports ACTIVE and the send completes through Resend.
Operational
- Re-provision Novu per environment (`pnpm provision:novu`) — the fix lives in the workflow template, so it only takes effect once the environment is re-provisioned.
- `offer.decided-on-behalf` was missing from staging and production and is created by the same run. It fires when Cypress declines or counters an offer under an org's delegation, so those FM notifications had been going nowhere.
- Both environments also carry a stale `vehicle-price-changed` workflow, retired from the catalogue on 2026-08-26 with zero trigger sites. Harmless, and left alone.
v2.6.1
2026-09-02🩹 The audience finds its people, and the flow editor opens again
Everything 2.6.0 shipped, put in front of a real book. One release-stopper and four places where the screen was quietly the last to know.
Fixed
- 🚨 The flow editor crashed on open — every flow, not only CRM ones. `NODE_ENTITY_SUPPORT` is a `Record<NodeType, …>`, and the sticky note is a palette item that is *not* an executable node type, so it has no key there. 2.6.0's palette filter cast it to `NodeType` anyway; the lookup returned `undefined`, `.includes` threw, and the canvas died before a single node was placed. The cast is gone, an unknown key is treated as entity-agnostic — which is what a sticky note is — and the gate is unweakened, because the `Record` still forces every real node type into the map.
- 🏷️ A tag filter now reaches the people at tagged businesses. Bulk tagging happens on the Businesses grid — tag four hundred dealerships *West Coast*, then mail them — but the audience read `Contact.tags` alone and matched nobody. A tag now matches the contact or the business they work at, and the field suggests the book's real tags as you type (businesses included, ranked by how many records carry each), so the filter is a choice instead of a guess.
- 📓 A campaign says whose book it reads, and Cypress can move it. A CRM audience always resolves the *campaign owner's* contacts. That owner was chosen once at creation — defaulting to whatever org the acting-for lens happened to be on — and then never shown again, so a campaign named "Cypress managed" sat in a seller's book, resolved their two contacts, and reported nobody above the words *"this reaches your entire book"*. The panel now names the book and its size (*"0 of 2,007 in Cypress (platform)'s book"*), explains an empty one instead of blaming your filters, and lets Cypress re-point a campaign that has not yet sent. Sellers cannot — choosing an owner is a platform privilege, as it already was at creation — and once a campaign has sent, its owner is fixed, because the sends and the suppression history belong to the book that was actually reached.
- ⌨️ Tag and address fields accept a second entry. Both were `value={list.join(…)}` with a split on every keystroke: press Enter or type a comma and the split produced an empty segment, `filter` dropped it, and the value re-rendered without the separator you just typed. The tag box ignored Enter entirely; the email list could never hold more than one address. Raw text and parsed list are now separate state in both, with the paste handling kept — quoted CSV cells, `<brackets>`, `mailto:` prefixes, case-insensitive de-duplication.
- 📮 A send Novu silently drops is no longer reported as sent. Novu answers a trigger for a workflow that does not exist with `201` and `{ acknowledged: true, status: 'processed' }`. Nothing throws; the workflow lookup happens later inside Novu's own queue and the caller is never told. So a campaign fired at an unprovisioned environment recorded a clean SENT for every recipient while not one email existed — exactly the confusion 2.6.0's delivery analytics were built to remove, invisible to them. Both send workers now verify the workflow once per batch and fail the send with the reason instead of minting phantom sends. The check is deliberately one-directional: no credentials, an unreachable API, an unexpected shape, any non-404 error all proceed and leave the outcome where it was, so it can turn a silent success into a loud failure but never invent one.
Operational
- No migrations, no environment changes.
- Novu workflow catalogue is now 100 (was 92 when staging and production were last provisioned). Run `pnpm provision:novu` per environment — the new `campaign.message` / `campaign.inventory` workflows are what CRM campaigns dispatch through, and a missing one is now a loud failure rather than a phantom send.
- Known gap, unchanged: Resend reports delivery and bounces over a webhook, so those columns stay blank in any environment Resend cannot reach — a local dev machine included, where clicks still work because the redirect is first-party. Correlation is by email address, since Resend's payload carries no recipient id.
- Known gap, unchanged: the in-app step of the campaign workflows fails its bridge execution on an invalid `redirect.url`. Email is unaffected.
v2.6.0
2026-09-02🗺️ The book knows where it is, and the send says what happened
Living with the v2.5.0 import at real scale — ~2,900 rows in one file — surfaced what a book of that size actually needs. A location you can filter on. A grid that groups. A way to act on four hundred records at once. Flows that fire on people and companies, not only cars. And a campaign screen that can tell "nobody opened it" apart from "it never went out." Five workstreams, one release.
Added
- 📍 Addresses arrive, ZIP first. Businesses' locations gain a ZIP and a street address; contacts get an address of their own — a consumer or a direct relationship has no company to inherit a location from. Both follow the platform's existing convention from the vehicle forms: type the ZIP, the state and city fill themselves, and anything you already typed is left alone. One shared resolver (`resolveUsZip`) now backs the new paths, with a process-lifetime cache and a batch mode that gives each unique ZIP exactly one request — a failure is never fatal, it just leaves the field to you.
- 📮 The import decodes ZIPs, and the ZIP wins. Map a *Zip* column and the dry run resolves the file's few hundred unique ZIPs into cities and states before anything is written. A row whose state disagrees with its ZIP is corrected, and the correction shows on the row — the state column of a scraped list is the field most likely to be wrong. A city you supplied is kept; a ZIP that doesn't resolve is a warning, never a blocked row. A row with a company describes the *company's* location; a row without one describes the person's. The resolution happens in the preview only, so what you approved is byte-for-byte what gets committed.
- 🧮 The Businesses grid grows the facts you filter on — Location (with a *+N* when a company operates from several), Tags, Fleet size and GMV — plus a state filter and group by state, fleet size, owner or source. Each group header carries its count and its summed GMV, so "how many in Texas, and what are they worth" is answered on the header row. Groups collapse, and a group's checkbox selects everything inside it.
- 🏷️ Bulk tagging, on both books. Select rows — or a whole group from its header — and Tag adds and removes in one pass: "tag these forty West Coast" and "take Q3-push off the ones that closed" are a single gesture. Removal ignores case, adds dedupe, the twenty-tag cap is respected, and every change is written to the record's history. It deliberately does not count as activity: labelling a thousand records must not reorder a book sorted by what happened recently. Contacts get the same action; businesses also gain Export.
- ⚙️ Automation learns the CRM. Record Created and Record Updated now carry a record-type selector — vehicle, contact, business or deal — and the whole canvas follows it: watch fields offer that record's own vocabulary, conditions read its fields, and a deal flow watching `stage` fires on pipeline movement whether a person dragged the card or the system derived it from an offer, because both go through the same door. Contacts and businesses fire on creation from *any* source, the CSV import included. Three new actions come with it: Add CRM Tag and Remove CRM Tag (a deal tags its company — deals carry no tags of their own) and Create CRM Task, whose title and notes render `{{contact.email}}`-style tokens from the record that fired the flow. Notify learned CRM records too, reaching the record's owner or every platform manager when it has none.
- 🚧 The palette only offers what fits. A contact flow is never shown *Apply Label* or *Set Floor Price*, publish refuses them if a trigger's record type changes under nodes already on the canvas, and the executor blocks rather than crashes if one slips through — three independent gates, because a vehicle action reaching a contact is the one failure mode this widening could produce.
- 📊 A campaign now says what actually went out. The analytics tab gains a sends history — one row per dispatch with when it was scheduled, whether it was Scheduled, Manual or a Test, and how it fared (asked, sent, failed, delivered, opened, clicked, bounced) — and a recipients table listing every address with the worst thing that happened to it: failed (with the provider's reason), bounced, unsubscribed, clicked, opened, delivered, sent or pending. Searchable by address, filterable by status and by send, and a recipient who is a contact in your book links through to their record. Opens and clicks stay first-party, counted from the pixel and the link redirect rather than the provider, so one open is one open.
Fixed
- 📤 A send that never left the building looked identical to one nobody opened. Both send workers wrote their own event rows and no counter ever knew how many dispatches actually reached the provider, so a campaign showing 0% opened had two very different possible explanations and no way to tell them apart. `SENT` and `FAILED` now roll up through the same door every other event uses, each recipient carries its own `sentAt` / `failedAt` and, on failure, the reason — which used to be a `console.warn` in a worker log nobody reads.
Operational
- One migration, auto-run on deploy (`crm_improvements`), covering three areas: business-location ZIP/address and contact address; the automation engine's `CRM_EVENT` trigger and per-step entity columns; and the campaign delivery columns (send trigger, sent/failed counters, recipient timestamps and failure reason, a `FAILED` event type, an email index). No environment changes and no new Novu templates — the new actions write in-app records and reuse the existing notification workflow.
- Deferred on purpose: four older ZIP fetchers (fleet import, two in the worker, the onboarding sheet) still carry their own uncached copy of the same lookup. Consolidating them onto the shared resolver mid-release would put regression surface on the platform's busiest import path for zero user-visible gain; it is a standalone cleanup.
- The Platform Manual updates four chapters: Importing your book, Contacts and businesses, Flows, and Marketing.
v2.5.1
2026-09-01Lago is decommissioned, and its resources come home
v2.2.0 moved invoicing to Stripe and switched Lago off behind `LAGO_ENABLED=false` — but the machinery kept running: six Railway services (API, worker, clock, PDF, front, and a dedicated Postgres), six local containers, and an activation path that still minted customers into a ledger nothing reads. This release retires all of it and reclaims the compute.
Changed
- Org activation stops touching Lago. `provisionBillingCustomer` — called on every approval and activation — now returns immediately under `LAGO_ENABLED=false` instead of writing to a decommissioned ledger (or, once the API was gone, raising a false "cannot be invoiced" alarm on every approval). The manual *Create Lago customer* admin action answers 410 Gone with the truth: billing runs on Stripe now.
Operational
- Final Lago-DB dump taken before teardown — uploaded to R2 beside the frozen nightly series (which stops being pruned the moment the backup job's Lago section deactivates) and archived locally, integrity-verified.
- Railway (cypress-zero-ops): the six Lago services deleted; `LAGO_DB_*` removed from the backup service so the nightly run — which `set -e`s — cannot die trying to dump a database that no longer exists. Shared Postgres/Redis untouched (Lago used Redis db 3 on the shared instance and its own dedicated DB).
- Railway (cypress-mark-2): dead `LAGO_API_URL` / `LAGO_API_KEY` / `LAGO_WEBHOOK_SECRET` / `LAGO_ID_PREFIX` removed from web-v2 and worker in staging and production. `LAGO_ENABLED=false` deliberately stays — the flag defaults to *enabled* when missing.
- Local: the six Lago containers and their images removed from the zero-ops stack, compose definitions retired, the local `lago` database dropped after a dump.
- The `billing-api.getcypress.ai` DNS record now points at nothing and can be deleted at the registrar.
v2.5.0
2026-09-01📇 The book fills itself: CRM import arrives
Prospect lists stop being spreadsheets someone means to type in one day. **CRM → Import** takes the file as it is — a conference dealer list, another CRM's export, a colleague's collection of columns — and turns it into contacts, businesses and deals in one pass, matched against the book so nothing you already know is ever duplicated. The first real cargo through it: ~2,900 rows of dealerships that landed as 1,967 contacts and 2,352 businesses, found afterwards with a single filter. And because a 3,000-record book is a different animal from a 30-record one, this release also makes the CRM lists show *all* of themselves instead of a silent first page.
Added
- 🧙 The CRM import wizard — Upload → Map Columns → Review → Done, the fleet importer's visual twin, at CRM → Import (also a button on the Contacts and Businesses tabs). One template covers three record types: a row with an email creates or matches a contact, a row with a business name creates or matches a business, a row with both files the person under the company, and optional deal columns open a deal on the row. Only an email *or* a business-name column is required — every other column is optional, which is what lets an emails-only list and a fully-columned CRM export walk through the same door.
- 🧭 A mapper that reads your file, not just your headers. "Dealership", "Main Phone" and "Address 1: Primary Contact Name" land where they should by name; a column the header doesn't explain is matched by its *sample values* — a column of email addresses maps itself, a column of "Arizona"s becomes State. One CSV column per Cypress field, highest confidence wins, every guess badged so it can be overruled.
- 🔍 A dry run that tells the whole truth before anything is written. Every row carries up to three intent chips — contact / business / deal, each new, update (fill-blanks-only: an import never overwrites a value someone typed), match, skip or error — with per-row warnings, fill-blank diffs, entity-level totals ("27 new businesses, 2,410 new contacts"), a warnings/errors filter and an Issues CSV export. Messy cells become warnings, never rejections: `GM: Brian` becomes Brian with a note, `admin@x.com,` sheds its comma, a mangled phone is kept as typed — one bad cell cannot sink a 2,000-row file.
- 🤝 Matching that knows a dealership when it sees one twice. Contacts are matched by email within the book; businesses by name ignoring case, punctuation and legal suffixes — *Goldie's Motors* and *GOLDIES MOTORS INC* are one company — and rows sharing a company email domain merge under one business. Multi-contact dealerships group; States normalise ("Texas", "TX", "texas" → TX) onto the business's location; a new business that merely *resembles* an existing one is flagged, never auto-merged. Re-importing a file is a no-op by design — the second pass matches everything the first created.
- 📦 Batch options with a pipeline switch. A source label and tags stamped on everything created — and "Open an onboarding deal for each new business", which turns a raw prospect list into a working Prospecting-stage pipeline in the same import. Deal columns respect the earned stages: a file may set Prospecting, Interested or Qualified, never the stages a deal has to win.
- ⚙️ The commit is a background job with a live progress screen — 202 and a polling batch record with per-row outcomes, exactly like fleet imports. The worker resumes instead of double-writing after a retry, records fill-blank updates into each record's history, and the done screen lists any failed row with its reason.
- 🏷️ Provenance you can filter by. Businesses gain the `source` column contacts already had; the importer stamps it (plus first activity) on everything it creates; and both the Contacts and Businesses tabs grow a Source dropdown with live counts — deep-linkable, which is what the done screen's *View imported contacts / businesses* buttons use to open the book pre-filtered to exactly what the file produced.
- 🧭 The CRM nav grows up. Contacts / Businesses / Deals — three links to the same page — become one CRM entry with Import beside it, mirroring Onboarding's Fleet + Import pair.
Fixed
- 📃 The CRM lists showed 100 rows and called it everything. The pages fetched one capped API page and paginated it client-side, so a 3,000-contact book displayed its first hundred as if that were the book. The lists now load the whole filtered set (parallel 1,000-row pages) and the count label finally agrees with the filter chips.
- 🔁 A dropped import commit can no longer be lost — or doubled. The commit POST retries itself through transient network failures, and carries a per-review idempotency key the server honours: a retry whose predecessor actually landed gets the existing batch back instead of minting a second one (contacts and businesses would have deduplicated anyway; deals would not have).
- 👻 Import errors were written in invisible ink. The wizard's error banner used colour tokens that don't exist in the design system and rendered as bare black text; it now uses the real error palette and says plainly that nothing was imported and retrying cannot duplicate.
- 🧮 The review's business count now matches what the import actually creates. The dry run counted same-domain businesses separately while the commit (correctly) merged them; the preview now merges the same way and flags each merged row, so the promise and the result agree.
- 💡 All three CRM nav entries used to light up at once (three links, one pathname) — collapsed into the single entry above, and a nav link can now exclude a sibling's sub-path, so CRM goes dark while you're on its Import page.
Operational
- Two migrations, auto-run on deploy: `CrmImportBatch`/`CrmImportRowResult` (the batch record) and `CrmBusiness.source`. No environment changes, no Novu templates — progress lives in the wizard, not notifications. The Platform Manual's CRM chapter gains Importing your book.
v2.4.2
2026-09-01Every seller archive is reviewed, and the review queue stops hiding
2.4.1's audit of the archive machinery surfaced a policy contradiction: the fleet panel staged a seller's archive for CM review while Seller Hub archived the same inventory immediately, so whether a removal got reviewed depended on which page the seller happened to be on. This release picks a side — review, always — and then makes sure the resulting queue is impossible to miss, because a vehicle with a pending archive request is deliberately untouched: still listed, still able to take offers, until a CM decides.
Changed
- Seller Hub archives now stage instead of applying. The delist endpoint became role-aware: a platform CM still archives immediately (the CM *is* the reviewer), while a seller's call — the archive sub-view, the row quick-action, the bulk bar — creates the same pending Archive request the fleet panel always did, de-duplicated to one open request per vehicle and audit-logged as coming from Seller Hub. The sub-view says what will actually happen ("submits an archive request… the vehicle stays listed until approved"), the submit button says "Submit archive request", and every staged response raises a toast saying the vehicle remains live. The review exists on purpose: an org that onboards, values, and immediately archives inventory is using the platform as a free appraisal desk, and the request queue is where that pattern shows.
- Filing the request pauses a live listing — and the wait is visible. The moment a seller's archive request lands on a LISTED vehicle, the listing pauses (the seller just said the car should come off the market, so it stops taking new buyers immediately); existing offers stay open and untouched, because the irreversible cleanup belongs to the approval. A rejection resumes the listing with its negotiations intact. And the request is never a lost click anymore: the vehicle carries an *Archive requested — awaiting Cypress review* banner in the fleet side panel and the Seller Hub panel (`pendingArchiveRequest` on both list APIs), every submit toast says the listing is paused pending the decision, and both grids mark the row with a small amber ARCHIVE REQ. chip under the status badge — the status itself deliberately untouched — whose hover tooltip carries the full story (paused meanwhile, approval archives, rejection resumes). One shared chip component serves both grids, so they cannot drift. All three staging doors now share one implementation (`stageArchiveRequest`), so the pause, the dedupe, and the audit trail cannot drift apart again.
Added
- The BackOffice nav wears its queue. *Update Requests* carries a badge with the pending-request count and *Archived Vehicles* with Post-Sale archive records whose decision toward the seller is still owed — counts from a new CM-only stats endpoint. Post-Sale-only for the second badge is deliberate: routine archives default to a PENDING decision and legitimately stay there, and a badge that counts them would be permanent noise.
- Mission Control raises pending archive requests as a decision item — top urgency, CM boards only, loudest when it matters: the item names how many of the requested vehicles are still live on the marketplace taking offers, sums their at-stake target prices as blocked value, and lands on Review requests → Update Requests.
Operational
- No migrations, no environment changes. The Platform Manual's archive chapter (rewritten in 2.4.1) now documents the unified policy and the badges.
v2.4.1
2026-09-01Locking the side doors: the satellite apps get auth, sellers get their tools, the database gets a ceiling
A hotfix with one headline and a common thread. The headline: ARVIS (`inspect.*`) and Orbis (`data.*`) had been deployed with no authentication at all — every page and API answered anyone on the public internet, including the new-inspection page, which listed every organization, fleet, vehicle and VIN on the platform. The thread: a dealership's own ADMIN kept being mistaken for platform staff by the UI (handed archive-outcome modals the server would only refuse) while being denied the seller tools they actually own. Both are the same confusion about who someone is, resolved in opposite directions.
Security
- ARVIS and Orbis only admit you through the platform now. Neither app grows a login; instead web-v2 — which holds the real session — mints a short-lived signed token at `/api/sso/arvis|orbis` and hands the browser over; the app exchanges it for its own eight-hour cookie, verified by new middleware on every request. Fail-closed: without the shared secret configured, nobody gets in rather than everybody. Machine callers (the worker's valuations and VIN decodes, web-v2's inline valuation) authenticate with an `x-internal-key` header instead; `/api/health` stays probeable.
- What you see there is scoped to who you are. In ARVIS, platform CMs see everything; a seller org's ADMIN or Fleet Manager sees only their own org — the inspection list, the new-inspection cascade, the detail page, and every per-inspection API resolve other orgs' records as not-found. Orbis remains what web-v2 always presented it as: platform-staff only.
- The local LM Studio engine no longer exists on deployed instances. It is a developer-laptop backend; the picker hides it, the create API refuses it, so the worker can never again drain a queue of jobs addressed to `localhost:1234` (the CYPRESS-WORKER-16 tail).
Fixed
- A dealership admin no longer wears the Cypress Manager UI. `/api/me` derived `uiRole` from the role string, which is `ADMIN` for every org owner — so Astro Group's admins got the CM fleet view and the CM-direct Archive modal (responsible party, penalty toward the seller — toward *themselves*), and the server rightly answered Unauthorized. `uiRole` now derives from platform staffhood, and the fleet, seller-hub and post-sale pages gate their CM rendering on `isPlatformCM`, never the role-only flag. The client-side sequel to 1.9.1's fifteen-copies server sweep.
- Everything a Fleet Manager can do, the org's ADMIN can do — audited end to end. The API side already held after 1.9.1; the two stragglers were notification fan-outs (listing paused/resumed) that targeted `FLEET_MANAGER` only, which for admin-only orgs like Astro meant notifying nobody. Both now include ADMIN.
- Sellers can edit their vehicles through the sale pipeline. The side panel offers *Edit details* in the same states CMs get it (in review, pending approval, listed, on hold, under offer), and the onboarding sheet's FM lock now engages only once a deal is in motion or the vehicle has left the market — not the moment it leaves DRAFT. Draft-only affordances stopped leaking along the way: *Submit for Review* shows only in draft, and the save button says what it does ("Save changes").
- The Manheim Condition Report opens to sellers, and to correction. The benchmark section was CM-only and write-once: once a report imported, the URL was view-only forever (the unsupported-provider state had no edit path at all). Sellers now get the fetch flow (the API was always org-scoped, never role-gated), and both completed states gain *Replace URL* — prefilled, with a warning that replacing overwrites the imported damages and photos.
- The admin Business Organizations "Draft" filter 500'd. The page sends six legacy statuses as a comma-joined list; the API passed the raw string into Prisma behind an `as never` cast (CYPRESS-MARK-2-8), and the page then crashed parsing the empty error body (CYPRESS-MARK-2-9). Statuses are split and validated against the enum now, and the page guards non-OK responses.
- One service can no longer starve the database. Prisma sizes its pool to the *host's* CPU count — 48 on Railway, a 97-connection entitlement per service against Postgres's 100. On 26 August web-v2's pool ballooned to ~91 during a burst, starved the worker and ARVIS ("too many clients already", CYPRESS-WORKER-14 / CYPRESS-ARVIS-1), and then sat on the connections idle for six days — production was found at 99/100. The shared client now caps every service at `connection_limit=10` (override: `PRISMA_CONNECTION_LIMIT`) with a 30-second pool timeout so bursts queue instead of erroring; the 62 stale idle connections were terminated by hand the day they were found.
Operational
- Required before this deploys: `CRON_SECRET` on the arvis and orbis services (staging and production), same value web-v2 and the worker already carry. It signs the SSO tokens and authenticates machine calls; absent, the apps lock everyone out — including the worker's valuations. Optionally `WEB_APP_URL` per environment for the unauthenticated-redirect target (defaults to the production app URL).
- No migrations. After deploy: the satellite apps should answer an anonymous `curl` with a redirect rather than 200, and `pg_stat_activity` should show each service holding ≤10 connections.
v2.4.0
2026-08-26Delegation: what Cypress may do for you is now something you grant
Cypress managers have always operated inside customer accounts — the question was on whose authority. The honest answer was "a role string": a checkbox labelled *Cypress Managed* that gated nothing, an offer panel that decided who may accept by comparing the offer to the floor price, a `COUNTER` action with no role check at all, and — worst — two vehicle routes that trusted an `actorRole` field in the request body, so any authenticated user could claim to be a Fleet Manager, perform FM-only transitions including the floor-price consent, and have the audit record the claimed role as fact.
Changes
- Cypress operates our account — seller lifecycle: onboarding vehicles into the org (single-vehicle create finally lands in the customer org under the acting-for lens, not in ROOT), floor pricing, the FM-lane transitions, listing, due diligence, and countering offers. On by default — this is the managed service the platform exists to provide, and defaulting it off turned every un-ticked org into a blocked import; it is an opt-OUT for organizations that keep the lifecycle in their own hands.
- Decide review-range offers — accept/reject between floor and target, off by default. Below-floor acceptance never leaves the org's own Fleet Manager, under any combination of switches; that line is not delegable.
- Make offers as us — the buy side, off by default — and previously ungated: a CM session was silently scoped to every customer org's buyer actions. It now requires the acting-for lens plus this switch.
Changed
- One accept, decided by delegation. The five role-named offer actions (`FM_APPROVE`, `CM_APPROVE`, `FM_REJECT`, `CM_OVERRIDE_REJECT`, `COUNTER`) collapse into `ACCEPT` / `REJECT` / `COUNTER`; legacy names remain permanent aliases with their encoded role ignored. The org's own FM/ADMIN decides any offer in any band (below floor stays "waived by FM"); Cypress — ROOT admins included — decides review-range offers only under the delegation switch, counters under either seller switch, and can never touch a below-floor decision. The unreachable dual-approval limbo ("Approved — awaiting Fleet Manager") is gone; `fmApproved`/`cmApproved` stay as the record of who signed. `COUNTER` is role-gated for the first time — a `BILLING_ADMIN` could previously send a binding 48-hour counter.
- The seller-hub decision panel follows the same rules instead of splitting lanes by floor price and gating on a role flag that is true for every dealership owner. FMs get the full accordion in every band; Cypress sees exactly what was delegated, with a clear below-floor "FM decision required" card; refusals surface the server's reason instead of failing silently. The Resp. column says "Cypress" only for orgs that actually delegated review decisions.
- The fleet page's VIEW AS toggle is now the preview its tooltip always claimed to be. The server derives the role from the session and each vehicle's org delegation; `actorRole` in the body is accepted and ignored so stale clients don't 400.
- Review-range intake notifies Cypress when delegated — the account manager if one is assigned, otherwise all platform CMs — and the below-floor notification stops promising a "CM approval" step that no longer exists.
- Floor-price writes are gated server-side everywhere (single PATCH, batch, and the transition payloads): the org's FM/ADMIN, or delegated Cypress staff. The fleet UI said FM-only for a year; the API now agrees.
Added
- On-behalf is visible. `AuditLog.onBehalf` (written since 2.3.0, read by nothing) is now stamped on every delegated write — offer decisions, transitions, floor changes, imports, buyer-side actions, settings edits — and rendered as an amber Cypress · on behalf badge across the vehicle timeline, seller-hub activity, admin org feed, and platform activity page. A delegated acceptance also drops a notice into the seller's Updates feed, and every delegated decision notifies the org's FMs ("— by Cypress on your behalf") via a new `offer.decided-on-behalf` Novu workflow.
- `resolveDelegation` in `@cypress-mvp/shared`: the single translation from (actor, org consent) to capabilities, used by the offer path, the vehicle state machine, and the buyer-side gates — no more role-string authorization in those paths.
Removed
- `Organization.cypressManaged` (the inert checkbox — orgs that had it ticked were seeded into the new act-on-behalf switch), and the never-read `autoApproveAboveFloor` / `floorMultiplier` columns.
Also in this release
- The buyer hub names both parties — for Cypress eyes only. A CM scanning offers across orgs now sees whose vehicle it is (owner stacked under the fleet, as the seller hub does) and which org made each offer, with the person beneath. Both are gated server-side: sellers stay anonymized from buyers per the v1.5.0 policy, and the API returns the names as null outside a staff session. Found on the way: the buyer-hub list ignored the acting-for lens and showed a CM every org's offers — it now trusts the lens like everything else.
- The marketplace no longer sells you a refusal. The public action panel ignored the server's response and showed the submitted state anyway — a leftover demo behavior on a *binding purchase flow*. Refusals now surface their reason, and Cypress staff viewing a listing without buyer delegation get a "Buying not delegated" card instead of a Buy Now button that could not succeed.
- Search survives the round trip. The public marketplace keeps its search, filters, sort and page in the URL — Back from a vehicle (or the breadcrumb, which now behaves as Back when you came from the catalog) restores exactly where you were; reloads and shared links do too. Storefronts inherit it.
- The public condition report becomes a real Arvis card: Manheim-scale grade, drivability/mechanical chips (rendered only when actually known), damages and photos counts, tire depths, inspection date, and a shield-iconed, deliberately unbranded "3rd-party report" link. Repair costs are excluded at the query level, and the provider's name never leaves the server. Fixed en route: the card's data source — imports live in REVIEW status on the report's own linked inspection, which the FINALIZED-only query never found.
- The grids survive narrow screens. A full overflow audit of the fleet and seller-hub boards (24 defects): long vehicle titles, VINs, locations, action buttons and price chips now truncate with hover tooltips instead of running under their neighbors; headers stop desyncing from rows; the fleet container's hand-typed min-width is now computed from its columns. Buyer hub and analytics grids inherit the shared-cell fixes.
- Smaller sharp edges: the acting-for bar stays pinned below the header while scrolling (its comment always claimed it would); the admin org page's Save button got its lost background back and failures now surface as toasts instead of vanishing (the "save didn't work" report was an invisible button over a silent error); the marketplace/storefront hero drops to half height so inventory is above the fold; and the admin Business Orgs grid gains a Delegation column in the same chip vocabulary.
- Docs edition 1.5 ships alongside: a new Getting Started chapter (*Delegation — Cypress on your behalf*) and delegation banners across the ten chapters the switches govern, in the app's own Lifecycle · Sell · Buy badges.
Operational
- Two migrations: `20260827090000_org_delegation_flags` adds the three flags, seeds the lifecycle switch from `cypressManaged`, and drops the three dead columns; `20260827110000_act_on_behalf_default_on` flips the lifecycle switch to default-true and backfills every existing org (none had deliberately opted out — the switch ships in this same release).
- Novu: provision the new `offer.decided-on-behalf` workflow in staging and prod (`scripts/provision-novu-workflows.ts`) before real sends.
- Docs: `pnpm docs:tldr` pending for the new/edited chapters — LLM credits were depleted at release time; existing summaries preserved.
- Operating rule for the team: never buy a vehicle while acting under the Cypress (ROOT) org itself — always act on behalf of a real buyer org (and sell on behalf of the seller org). ROOT-owned purchases pollute transaction history and analytics; the v1.7.8 cleanup repointed 84 offers wrongly attributed to Cypress, and delegation exists precisely so that never happens again.
- The manual was rewritten where it described a dual-approval flow the code never had: *Offers, counters & Buy Now*, *Offer Rules*, *Roles & navigation*, and the audit chapter now tell the delegation story.
v2.3.1
2026-08-26The quiet failures: spend, silence, and errors that named the wrong thing
A day of production archaeology rather than new surface. Every item below was already happening; the work was noticing. Several had been running for weeks behind a log line that read correctly, which is the thread connecting them: a guard in only one of nine places, a fallback that turned a typo into a connection error, an audit write that failed after the request had already succeeded.
Fixed
- The Black Book refresh sweep had thrown on every run since 19 August. `make_interval(days => $1)` has no bigint overload and Prisma binds a JS number as bigint, so the staleness query failed before a single valuation was fetched — seven days of live inventory unpriced while the job reported nothing but a caught error. The first run after the fix priced the 85 vehicles that had accumulated, for $25.50.
- The 14-day "do not re-price" rule existed in one place out of nine. It lived in that sweep's `WHERE` clause, which made it a property of one producer rather than of the spending. The other eight — imports, third-party reports, Cedar, the revalue buttons, and above all the automation engine's Request Valuation node, which fires as often as its trigger does — never consulted it. The window now sits in the valuation worker, immediately before the money is spent, and `force` is the only way past it: the revalue buttons, bulk re-valuation, and a condition report with no stored response to re-derive from. Automation and Cedar now say a vehicle was valued recently instead of promising a refresh that will be dropped.
- Production ran inspection analysis against a laptop. `ARVIS_VLM_PROVIDER=google` matched neither literal the router accepted, so it fell through to a `default` branch that called LM Studio on `localhost:1234` — nothing listens there in a container. Three months of `ECONNREFUSED` while the Google key was set the whole time and the boot log announced "provider: google". Vendor names are accepted now, and an unrecognised one stops the job naming itself rather than quietly degrading to a backend that only exists on a developer's machine.
- …and then billed the wrong model. With the connection fixed, `ARVIS_GOOGLE_MODEL=gemini-2.5-flash` turned out to be read in exactly one place: a log line. The call derived its model from the provider alone, so a request for Flash would have been charged at Pro's $1.25/$10.00 per million tokens, beside a boot log printing "gemini-2.5-flash". A bare vendor name now defers to the configured model, and the log is derived from what will actually be called.
- Four insight crons never finished. Each walked up to 200 entities with one LLM call apiece inside the worker's five-minute HTTP timeout; the request was aborted mid-flight, the retry restarted from the top, and the tail of every list went unreached on every attempt while the head was regenerated each time. They now stop on a wall-clock budget and report what they deferred, because latency is what overruns, not entity count.
- Scheduled jobs gave up faster than Postgres came back. Three attempts on a one-second base spent themselves inside the ~30 seconds a database restart takes, so any sweep running during a deploy failed permanently. The window is now wide enough to outlive a restart — but the minute-crons deliberately do not retry at all, because `automation-schedule` decides what is due from a 70-second look-back and two overlapping runs fire the same trigger twice. A gateway 502 is retried in-process instead, since that one proves the route never ran.
- A tracked campaign click 500'd when the campaign had no promotion. The route every link in a campaign email redirects through reached straight through an optional relation, so a CRM campaign's recipient would have got an error page instead of the vehicle they clicked, with the click unattributed.
- An audit entry addressed to `'PLATFORM'` was never written. Five call sites turned a platform-level checklist item's null org into that literal string; `AuditLog.organizationId` is a foreign key, so every one of those writes failed. The enqueue is fire-and-forget, so the request succeeded and the entry simply never existed — losing the audit trail for precisely the changes staff make to shared templates.
- A Stripe webhook answered 500 for records that will never exist. Stripe reads that as "try again" and redelivers for days, counting against the endpoint's health, while no retry could succeed. Events referencing objects the replaced Stripe account owned are now acknowledged and logged instead — narrowly, matched on the error code, so a genuine fault still fails and is still retried.
- A CM's invoice-PDF replay was discarded over a field it barely needed. `closingStepId` is used for one of that job's four steps; demanding it up front threw away the download, the upload and the invoice status too, so jobs queued before the field existed failed on every retry and the invoice sat FAILED with no way forward. It is derived now, and when nothing resolves the PDF is still stored and the invoice still marked ready.
- An abandoned browser tab produced a failed login every minute, forever. The onboarding banner polled while an org was provisional, each poll resolving a session that had expired, at roughly 5,700 log lines a day from a single tab. It gives up after three and lets regaining focus be the retry.
- `ImportBatch.jobId` was unique, and a BullMQ job id is not. Ids restart at 1 whenever Redis is flushed, so a leftover batch blocked the next import that drew the same number; and the API pre-creates batches with the literal `'pending'`, which two simultaneous imports race on. Nothing ever looked a batch up by that column.
Operational
- One migration, non-destructive: `20260826120000_import_batch_job_id_not_unique` drops a unique index. The plain index on the same column is kept, so no lookup changes.
- No environment variables changed. `ARVIS_VLM_PROVIDER=google` and `ARVIS_GOOGLE_MODEL=gemini-2.5-flash` are now honoured as written rather than needing correction.
- The Black Book spend ledger (`ValuationInvocationLog`) is the record to check after this ships: steady state is roughly 7 vehicles a day, and the daily ceiling alerts at 400 calls.
v2.3.0
2026-08-25CRM and Marketing: the prospect side of the business, in the platform
Until today the commercial team ran the prospect side of this business outside the platform. Contacts lived in a spreadsheet, the list of who to mail was rebuilt by hand each time, the mail itself went from a personal inbox, and what came back — a reply, a call, a "not this quarter" — was remembered by whoever sent it. Cypress could tell you everything about a vehicle and almost nothing about the person who might buy it: interest arrived as an engagement event, became a chart, and stopped.
Added
- A CRM. Contacts, businesses and deals, each a full record rather than a row: lifecycle, owner, tags, locations, working hours, qualification (fleet size, brands, annual GMV), and a timeline of what has actually passed between you. A business timeline includes its people's interactions, because a call with Acme's GM is a thing that happened with Acme.
- Two pipelines on one board. *Sell vehicles* and *Onboard companies*, dragged between stages, filtered per kind or All with a category badge. Closing asks what the deal converted into; losing asks why. A deal carries vehicles individually or a whole promotion — a curated list is already a vehicle group.
- Deal temperature, computed rather than typed. An offer in 14 days is hot; talking within 7 with a stage that moved within 14 is hot; quiet past 21 days is cold, as is quiet past 14 with no next task scheduled to change it. The badge carries its reason and the days quiet, so it can be argued with.
- Tasks in three views — list, board by horizon, and a calendar showing what is owed beside what already happened. The same three appear inside a contact, a business and a deal. Cedar ranks the open set and names which to start with: lateness counts but is capped at a fortnight, so one forgotten task cannot outrank a live one forever.
- Marketing as a standalone module. The campaign engine leaves promotions and gets its own front door — because mailing a curated list and writing to twelve prospects are the same job, and only one of them used to be possible in the platform. One audience language across both: your storefront subscribers, your CRM book filtered live by lifecycle, tags and owner, or an email list you paste; Cypress additionally reaches the platform directory. Campaigns carry a promotion or your own written message, personalised per recipient with fallbacks that never leak a raw token, and a goal that any sourced deal inherits — so a campaign and the pipeline it feeds can never disagree about what it was for.
- The audience is a query, not a list. "Engaged contacts at fleet operators, owned by me" resolves at send time against the current book, so it is right every time rather than right on the day the spreadsheet was exported. Preview it, see the estimated reach, and send yourself a test before anyone else gets it.
- What a send earns comes back. Delivered, opened, clicked, then on-site viewed / saved / offered — per recipient and per vehicle, attributed to the contact's own record and to the deal it sources. The reply that used to live in one person's sent folder is now on the company's timeline.
- Mission Control. One board over offers, pre-listing, listings, closings, onboarding, CRM and organic interest — grouped by lifecycle phase, ordered decisions-first, every row naming who owes it. Nothing is stored: rows are derived at read time, so the board cannot drift from the page it points at.
- Ownership, filterable. Everyone / Mine / Unassigned, counted before the phase filter. Work nobody owns is amber, because that is what rots in a shared queue.
- A navigation that follows the lifecycle — CRM, Onboarding, Sale, Buy, Post-Sale, Analytics — and gives every seller org the relationship tools rather than treating them as platform features sellers glimpse.
- The acting-for lens. A Cypress Manager works as one seller org: their fleet, their book, their promotions, their campaigns, their board. What you create belongs to them too.
- Owners are visible. Promotions and campaigns carry an Owner column for a caller whose list spans owners, and the same badge on the record's own header. Creating one names the owner before it exists, and Cypress may choose another.
- Analytics for all three seats. `/analytics` gives sellers, buyers and Cypress the same lenses, plus a Relationships lens for the people loop. Engagement reports open from a sortable, filterable grid — pick the subject, then read the report.
Fixed
- The acting-for lens only reached the fleet. `childOrgIds` was narrowed and `isCM` was not, and every book rule read `isCM ? the platform : my own org` — so a CM working as a dealer got that dealer's vehicles beside the whole platform's CRM. Worse on the way in: a promotion created under the lens was stamped as platform-owned, made for an org and invisible to it.
- Ten workflows the code triggered did not exist. Novu workflows are created from the slug of their name while the app triggers the identifier; nothing compared the two, so eight catalog entries had drifted and three more were never registered. Provisioning now refuses a catalog where the two disagree.
- A test send said "sent" before anything was tried. Delivery is a worker away, so the click could not know. It now waits and reports what happened, a failed send records the reason the mailer gave, and a campaign with nothing to render is refused rather than delivered as a heading and a footer.
- The message preview promised what the send could not deliver — both preview samples carried a hardcoded promotion, so `{{promotion.url}}` always previewed as a link and always arrived empty.
- Mission Control was silent about work that exists. Tasks due later than today, tasks with no date, and closings not yet overdue were all outside the window and unmentioned — so four open tasks in CRM sat beside a board that named none of them. Each section now closes its own books.
- Every aggregate said "N vehicles" regardless of what it counted, so "34 engaged contacts" carried "34 vehicles" underneath it; and the row glyph fell back to a car for everything without a photo.
- A seller could not own a campaign carrying their own promotion — a CHECK constraint written when promotion campaigns were Cypress-only required the owner column to be null.
- Every themed colour in the app was silently wrong, and 29 primary buttons had lost their hover state.
- CRM correctness: "500k" cleared the amount instead of saving 500,000; notes and change history outlived the records they belonged to; a logged call rendered as an email; a company's own timeline showed its contacts' notes and not its own; a promotion filter queried the wrong relation and silently kept stale results; linking a contact to a company sent a verb the route never exported.
Operational
- Twenty-three migrations, none destructive: the CRM module itself (contacts, businesses, deals, tasks, interactions), business qualification, locations and hours, the deal↔contact join, deal kind and origin, Sakura deal insights, the CRM change log, campaign engine widening, campaign goal and unified audience, campaign windows, send failure reasons, promotion visibility, acting-for audit, and a relaxed campaign ownership constraint.
- Novu must be re-provisioned per environment — `pnpm provision:novu:local`, or the same script against staging and production. It is a full sync: it deletes every workflow and recreates the catalog, which is how the ten broken triggers are repaired. Staging and production almost certainly carry the same faults.
- `CAMPAIGN_POSTAL_ADDRESS` and `NEXT_PUBLIC_APP_URL` must be set on the worker. The campaign sender refuses to run without the latter rather than pointing tracking, the open pixel and unsubscribe at the wrong host.
- After editing chapters, run `pnpm docs:tldr` to refresh the summaries and questions.
v2.2.0
2026-08-21Billing moves to Stripe, and stops guessing
Lago was quietly trying to charge every invoice the moment it finalized, using its own copy of each customer — a second collection system nobody had asked for, failing on every attempt, and invisible to us because its payments carried none of our metadata. That is the thread this release pulls. Stripe becomes the accounting ledger, Cypress keeps collecting, and the invoice a seller downloads is the same document either way.
Added
- Stripe Invoicing as the ledger. A Stripe invoice is created the moment a commission is invoiced, carrying our own `CYP-…` number, and is marked paid out-of-band when our Checkout or the ACH sweep collects. Stripe bills its Invoicing fee only on invoices it collects itself, so the ledger costs nothing — the layout matches what Lago produced field for field, down to the zero-rated tax line and the footer, and every PDF is mirrored into our own R2 bucket rather than living only in someone else's system.
- Invoice numbering is ours. The `CYP-F060-<YYYYMM>-<NNN>` series continues unbroken, allocated under a transaction-scoped advisory lock. The sequence is global within the month, not per-org — prod proved it, with 093 through 100 spanning two different organizations.
- Pay several invoices in one card payment. Tick the open ones and settle them together: one charge, one receipt, a line per invoice. Nothing is claimed while Checkout is open, so abandoning costs nothing and a different selection can be made immediately; the invoices become a consolidated payment only once the money actually moves.
- Pay a commission from the deal. The three "Pay in Billing" links in post-sale now open the same Checkout in place, instead of sending a seller to another page to hunt for the same invoice.
- What the money moved on. Invoices record the card brand and last four, or the bank and last four for ACH — the webhook had always fetched them for the receipt URL and thrown them away, so "which card?" was unanswerable.
- An ACH debit says when it will land, and survives coming back. "In ACH transfer" now carries Stripe's expected clearing date, a refused batch explains itself in Billing instead of only in a notification, and a bank return received after Stripe reported success — which pulls the money back out of the balance — reverses the batch, releases the commissions and flags the org, where previously nothing at all handled it.
- Billing details are editable. Billing email, tax ID, and the billing address — with the same ZIP lookup the rest of the app uses — were accepted by the API all along and rendered read-only, which is why 18 production orgs have no billing email. Commission Model and Payment Terms are derived from ACH status rather than typed, so the record cannot disagree with how the org actually pays.
Fixed
- Lago charged invoices nobody asked it to. Every USD customer carried a Stripe payment provider, so Lago attempted collection on each invoice as it finalized — individually, defeating the batching model, and invisibly, since its PaymentIntents carried only `lago_*` metadata and never reached our handlers. Detached in production; Lago is a ledger, not a cashier.
- One Stripe customer per organization. 51 of 52 Stripe customers had been created by Lago's own sync and never linked back, so the first ACH setup minted a second customer — the bank account landing on ours while Lago billed its copy. That is the whole story behind `payment_intent_unexpected_state: missing a payment method`.
- ACH setup survives a bank the customer cannot authenticate into. Falling back to routing numbers puts the setup on microdeposits, which our code rejected outright and stranded. The verification link is now captured, shown, and emailed, a failed verification is recorded rather than left pending forever, and setup refuses to start without a billing email — because that is where the mandate and the verification instructions go.
- An ACH batch in flight no longer claims to be settled. The batch row rendered a hardcoded "Settled" regardless of status, directly beneath a banner reading "awaiting bank settlement". Submission is not collection.
- A card payment is no longer described as an ACH one. Grouping, status pills, the payment column, receipt copy, and payment verification each assumed a batched invoice was collected by bank transfer; all of them now key on how the batch actually collects.
- Batch rows align for a seller. The seller column is Cypress-Manager-only, but batch rows rendered it unconditionally, pushing every later cell one column right for everyone else.
- Documents are reachable from the grid. Receipt and invoice links appeared only on batch rows; a paid single invoice, and every invoice inside an expanded batch, had none.
Operational
- Six migrations, three of them destructive: `commissionModel`, `paymentTerms` and `CommissionInvoice.paymentUrl` are dropped after verifying nothing reads them.
- Webhook events — `invoice.paid`, `setup_intent.setup_failed`, `charge.dispute.created` and `checkout.session.expired` must be subscribed per environment; `scripts/ensure-webhook-events.ts` makes the list canonical and only ever appends.
- `LAGO_ENABLED=false` switches an environment to Stripe-only without deleting anything. It defaults to enabled, so nothing changes until it is set.
- Stripe Dashboard, per account: business name, address and support details drive the invoice's From block. The "Pay online" link on invoice PDFs cannot be removed from code — verified against the API — so `invoice.paid` reconciles any payment made through it rather than letting one move money unseen.
- Backfills, dry-run first: `scripts/link-stripe-customers.ts`, `scripts/tidy-stripe-customers.ts`, `scripts/detach-lago-payment-provider.ts`, and `scripts/verify-invoice-archive.ts` — the last of which gates the Lago removal by proving all 33 historical invoice PDFs are readable in our own bucket.
v2.1.0
2026-08-20Cedar moves into the docs
The manual stops being a place you read and becomes a place you can ask. The platform's own assistant now lives in the documentation — not a lookalike widget, the same controls the app mounts — grounded in these chapters and able to walk you to them. And every chapter opens with a plain-English summary for the reader who has ninety seconds, not an afternoon.
Added
- The real Cedar in the documentation. `/docs` mounts the app's own Cedar: the header omnibox, the docked side panel, and full Copilot mode with the floating orb and voice. A new `read-docs` tool grounds it in the manual, so answers quote the documentation rather than improvising — and cite the chapter as a link you can click, which navigates without losing the conversation. Cedar knows it is in a reading room: its scenes become Platform Manual / Developer docs / API reference / Release notes / Back to the app, its actions become "Summarize this chapter" and "How do I do this via API?", and voice takes the same briefing, so a spoken question is answered from the manual instead of from general knowledge.
- Cedar for signed-out readers, too. Visitors who have never logged in get the same controls, not a downgraded widget — the same header, the same rail, the same "Find a how-to" search — served by a public, rate-limited endpoint that speaks the app's streaming protocol over the documentation corpus. No org data is ever in scope. Copilot mode stays a registered-user feature: every entry point opens a login-or-register invitation instead.
- A TL;DR on every chapter. Each chapter opens with a two-or-three sentence plain-English summary for a non-technical reader, marked as an AI summary. Summaries are pre-generated and committed, not produced on page load — readers pay no latency, and the text is reviewed like any other documentation prose.
- "Explain this to me" and three questions per chapter. One click (or ⌥E) hands the chapter to Cedar to explain in plain terms. Beside it sit three questions written for that specific chapter — "Can I accept an offer below my floor?", "What if I don't set any rules?" — which are also what Cedar offers in the header pill, the rail's suggestions, and its ⌥1-3 shortcuts, so the same three follow you whichever way you open it.
- The manual's own release timeline. `/docs/releases` now carries two histories: the documentation's editions (v1.0 through v1.3, each with what changed and the platform version it shipped beside) above the platform's own releases. The edition timeline and the sidebar badge read from one source, so the version a reader sees always has an entry explaining it.
Fixed
- A question asked from outside Cedar could be sent twice. Seeding a conversation from an "Ask Cedar" entry point consumed the pending prompt with a state update, and a re-invocation before that update flushed would send the same question again — visible as a duplicated bubble. Both consumers now guard against it.
- Copilot mode ignored questions asked from outside it. Only the docked panel listened for a pending prompt, so an "Ask Cedar" action taken while Copilot was open silently did nothing. Copilot now answers it in the Cedar Bar without dropping you out of Copilot.
Operational
- No migrations, no env steps. One new public route — `/api/public/docs-chat-stream` — carrying a per-IP rate limit shared with the existing docs endpoint; both are documentation-only and reach no organization data.
- After editing chapters, run `pnpm docs:tldr` to refresh the summaries and questions. It is incremental by content hash: only changed chapters cost a call.
v2.0.4
2026-08-19The platform gets its manual
/docs grows from developer documentation into the full story of the platform: a user manual for every seat at the table, sitting beside the developer docs it used to be.
Added
- The Platform Manual — 26 chapters at `/docs/manual`. Every feature documented the same way: what it is, how it works with a hand-drawn diagram, a worked example, the business rules, and a short technical note. The chapters follow the reader's journey — getting started, fleet & onboarding, valuations & the three-prices doctrine, selling and Offer Rules, buying, channels & promotions, Cedar & the AI team, automations & pricing rules, analytics, the 9-step closing, money, and governance — and one continuous deal (a 2022 Ford Transit 250, from VIN decode to auto-archive) threads the entire book, with the buyer chapters replaying the same negotiation from the other seat.
- Two documentation sections, one switcher. The manual and the developer docs are sidebar sections with their own trees and icons; `/docs` is a chooser landing. The 8 developer pages moved under `/docs/developers/*` with permanent redirects covering every old URL.
- Release notes at `/docs/releases` — the same generated release history the platform shows on Admin → Analytics, now public in the docs shell and linked from the top nav. One source, two surfaces.
- Version badges — the docs sidebar pins "Docs v1.0 · Platform v<version>", the platform number tracking each release automatically.
- Screenshot pipeline (infrastructure) — a manifest-driven Playwright capture script with seat-based sessions; the manual's screenshots land in a follow-up once the capture sessions are armed.
Operational
- No migrations, no env steps. `/docs` was already a public route; the new sub-routes inherit it.
v2.0.3
2026-08-19Payments can't fail quietly, activation can't go unnoticed
A dealer finished onboarding, a manager activated the org — and the dealer still saw "Limited access" and couldn't pay a single invoice. The trail led back to the 8/11 Stripe account cutover: the new live account never had ACH activated, and a checkout that asked for `us_bank_account` was rejected whole, cards going down with it. Every invoice payment since the cutover had silently failed. The capability is enabled now; this hotfix makes sure neither half of that morning can repeat.
Fixed
- Invoice checkout survives a Stripe account without ACH. Checkout asked Stripe for `card` + `us_bank_account` on every USD invoice, and an account without the ACH capability rejects the whole session — the card path died for a reason that had nothing to do with cards. Checkout now retries card-only when Stripe rejects ACH, and both checkout and ACH bank setup answer a clear 502 naming the payment provider instead of a bare 500 the modal renders as "try again".
- Activation reaches the dealer's open tab. The limited-access banner checked onboarding status once, on page load — a CM activating the org while the dealer's tab sat open changed nothing until a manual reload, and "log out and back in" became folklore for what is a per-request DB read. The banner now re-checks when the tab regains focus plus a slow interval (PROVISIONAL orgs only — no sockets, no push infrastructure), then refreshes the page in place with an "Onboarding complete" toast: banner gone, gates open, no re-login. The CM who activates sees a toast saying exactly that, so nobody asks the customer to sign out.
- The unlock news reaches every member. The fully-onboarded notification only targeted ADMIN members — an org whose sole member is a Fleet Manager (common when a CM sets the org up) told no one. It now goes to every active ADMIN, Fleet Manager, and Billing Admin.
Operational
- No migrations, no env steps. One account-level prerequisite is now explicit: the Stripe account behind each environment must have the ACH Direct Debit capability active — the live account lacked it from the 8/11 cutover until 2026-08-19, which is why no invoice was paid in that window (26 were open when found).
v2.0.2
2026-08-19The API joins the automation engine
The last parity gap from the partner test drive: API pushes now drive the same automations everything else does — and flows gain a say in who gets to trigger them.
Added
- API changes fire the automation engine. A vehicle pushed through the API now fires `ENTITY_CREATED` exactly like a CSV import, and API updates — vehicle fields, prices, launch, pause, resume, cancel — fire `ENTITY_UPDATED` with the changed fields, like a human edit. This closes the loop the draft-first design promised: an org flow on `ENTITY_CREATED` (set price → list) now launches API-pushed drafts too. All emissions are fire-and-forget; a queue hiccup never fails a push.
- Triggers can choose their sources. Every automation run now carries its origin — a person in the app, a CSV import, or the integrations API — and the `ENTITY_CREATED` / `ENTITY_UPDATED` trigger nodes gain a "Fires from" picker on the canvas. Opt-out by design: all three sources fire unless a flow unchecks one, so every existing flow behaves exactly as before while a flow that shouldn't react to partner pushes (or to CSV uploads) can now say so.
v2.0.1
2026-08-19First partner test drive, three rough edges
The partner's very first real push through the new API surfaced exactly the feedback a 2.0.0 needs: a vehicle created cleanly, but the embedded listing vanished without a sound, the webhook arrived in an unexpected vocabulary, and the Bruno kit refused to import. All three are the difference between an API that works and one that explains itself.
Fixed
- An embedded listing the org can't fulfill now fails the whole push, loudly. Pushing a vehicle with a `listing` block naming a channel the org hasn't been granted (say `DIRECT_LINK` before the Direct opt-in) used to create the vehicle, drop the listing, and whisper a `listingError` field inside an otherwise-happy 201 — no pricing applied, no listing event, and nothing that looked like a failure. Both surfaces now validate the channels *before* creating anything and answer 422 naming the ungranted channels, so a push either fully lands or clearly doesn't.
- Vehicle webhook bodies always carry pricing and condition. Manheim's unit shape has no price fields (offerings own the money), which made a Manheim-dialect `UNITS.CREATED` echo useless for reconciliation. Unit bodies now include the Cypress `pricing` map (`buyNowPrice`, `floorPrice`, `startBidPrice`) and `condition` as documented extensions.
- The kit now imports into Postman, Insomnia — and Bruno's own Import dialog. A generated Postman v2.1 collection (same 38 requests, same automatic id capture, derived from the Bruno sources by `scripts/generate-postman-collection.ts`) ships in the kit alongside the native Bruno folder. The docs now say plainly which door each client uses: Import takes the JSON; Bruno's Open Collection takes the folder — dragging the zip into Import was never going to parse.
- Pushed prices now become the vehicle's standing offer rules. An API launch used to leave the offer bands empty — every offer went to review, and the Offer Rules tab showed three dashes for a vehicle whose push had named its floor. Now the documented mapping applies automatically at launch and on reprice: auto-accept at/above `floorPrice` (consent given in advance), auto-deny below `startBidPrice`, the seller's review band between. Explicit launch-dialog values and CM pricing-rule sessions still outrank the derived bands.
- API changes now appear on the vehicle's Updates feed. Every API mutation wrote its audit row, but none wrote the `VehicleNotice` the Updates tab reads — an API reprice was invisible where a human edit, a Cedar action, or an automation would have narrated itself. The API services now pair the two the way Cedar does: price changes ("Floor $19,500 → $19,750 — via API", with before/after), listing launch, pause, resume, and vehicle detail/location updates, each targeted to the seller org and Cypress managers.
- API-pushed photos render everywhere. Partner photos store the partner's own image URL, but every read surface presigned it as a storage key — minting a URL to a bucket path that doesn't exist, so the marketplace hero came up black. `getPresignedUrl` now passes absolute URLs through untouched, which fixes the marketplace, storefronts, and webhook envelope bodies in one place.
- Mock Black Book data now says so. Fixtures were persisted as `source=BLACK_BOOK`, so every surface rendered them exactly like vendor data — green badge, counted in Black Book spend totals, indistinguishable from a real market price someone could quote a customer from. Fixtures now persist as `BLACK_BOOK_MOCK`, the valuation card shows an amber "Mock data — not real" instead of the vendor badge, and analytics filtering on real sources excludes them for free. Also: fixtures used to invent a make/model from the VIN hash (a 1983 F-150 came back a "Hyundai Tucson"); callers now pass the known identity.
Clarified
- The webhook dialect follows the subscription, not the API surface a change came through — a modern-surface push still arrives as `UNITS.CREATED` on a MANHEIM-dialect endpoint. The webhooks doc now states this next to the dialect switch.
Operational
- Migration `BLACK_BOOK_MOCK` enum value (additive) auto-runs on web-v2 boot. No other per-environment steps — Novu and seeds are unchanged from 2.0.0.
v2.0.0
2026-08-19Cypress becomes a platform other systems build on
A partner asked to push inventory into Cypress the way sellers integrate with Manheim. The answer grew into the largest single release since launch: a public machine-to-machine API with two dialects, signed webhooks that narrate the whole remarketing process, self-service integration management for every business org, a public developer portal with living documentation, and a complete testing kit. The 2.0.0 mark is deliberate — Cypress is no longer only an application people log into; it is a platform other systems integrate with.
Added
- Integrations API, two dialects over one core. The Cypress API (`/api/v1`) speaks modern REST — `vehicles` and `listings`, PATCH for partial updates, bulk endpoints to 500 items. The Manheim-compatible surface (`/api/compatible/v1`) mirrors Manheim's Units/Offerings/Subscribers conventions exactly — 201 + Location, update-is-POST → 204, 409 on a duplicate VIN, action URLs — so Manheim-shaped client code points here with a base-URL change. Both are thin translations over the same services; a vehicle pushed through one is fully manageable through the other.
- Draft-first, live when you say so. API-pushed vehicles land as drafts; review or automation launches them. An embedded listing block with `listingMode: IMMEDIATE` takes a qualifying vehicle to market in a single request. Pricing maps onto floor/target/threshold; channels map onto the marketplace, storefronts, and the auto-provisioned direct share link — validated against the org's channel grants.
- The floor is a promise. Offer handling is explicit about consent: at or above the floor, the seller's standing rules answer instantly — consent given in advance by setting the price. Below the floor, only the customer can accept; a vehicle is never sold below the seller's line without their explicit approval, and the audit trail records who gave it.
- CSV-parity data quality. Condition arrives as the industry 1.0–5.0 grade (stored with precision, tier derived with the importer's exact thresholds) with the tier enum as a fallback; a trim buried in the model field ("F-150 XLT") lifts out via the importer's trim dictionary. Removal is three explicit intents — pause, cancel, sold-elsewhere — and any removal against a mid-deal vehicle is staged for Cypress review instead of applied, exactly like the CSV update path.
- Signed webhooks for the whole process, documents as data. Orgs register callbacks (their own auth headers echoed back verbatim) and receive HMAC-signed, Manheim-style envelopes — five retries with backoff, dead-letter alerts, per-subscriber vocabulary (Cypress or Manheim names). The catalog covers the full journey: inventory and listing lifecycle, every offer outcome, and every closing step — including `CLOSING.DOCUMENT.GENERATED` and `SIGNED`, which carry the complete data-sheet JSON the paper was rendered from (the bill of sale's figures as fields, not just a PDF link), payment confirmations with method and reference, and cancellations with reason and culprit.
- Self-service Integrations, everywhere it's needed. Business orgs get an Integrations page (API keys shown once at mint, webhook endpoints with test-send, pause/activate) in a new Organization nav group; Cypress managers get the same panel as a tab on the org page plus a cross-org Integrations audit. The activity log is one TanStack table across channels — API deliveries and CSV imports today, email ingest tomorrow — with filters, sorting, pagination, and expandable full input/output payloads, redelivery included.
- A real developer portal. Public `/docs` runs Fumadocs with business-narrative documentation — integrator use cases, the vehicle lifecycle, sales channels, pricing and consent, de-listing, the closing walked step by step with the bill-of-sale payload as an example — plus Scalar rendering the OpenAPI 3.1 contract at `/docs/api`, with a coverage test that fails the build if the spec drifts from the shipped routes.
- A testing kit customers can run in fifteen minutes. A Bruno collection (38 requests in flow order, ids auto-captured; open the folder, don't import), Postman via the OpenAPI URL, a zero-dependency webhook receiver that verifies signatures in the terminal and simulates outages to demonstrate retries, and a step-by-step runbook — all downloadable from the quickstart.
Operational
- Migration `20260819120000_org_integrations_api` (additive: five tables, three enums, `VehiclePhoto.source`) auto-runs on web-v2 boot. New `partner-webhooks` worker queue — the worker restart on deploy picks it up. Per environment: run `pnpm provision:novu` (adds `integration.webhook-dead`), then seed the partner with `scripts/seed-partner-integration.ts` (secrets from env, printed once). `/docs`, `/api/v1`, `/api/compatible` are public middleware prefixes; API routes authenticate per-request via org-scoped keys.
v1.9.8
2026-08-18The closing stops being a black box
A test sale surfaced three ways the closing kept its work to itself: an error message that read `[object Object]`, a "Last Update" column frozen on something that happened 67 days ago, and a vehicle timeline that went silent for the entire sale. Fixing the reporting turned into a pass on the moments a deal is meant to speak up — and on the small dead ends that make a seller leave the page to finish a sentence.
Added
- The closing writes to the vehicle's timeline. Milestones — closing opened, each step completed, a Deal Setup side confirmed, the sale closed, a rollback — now appear on the vehicle's Updates feed for the seller, the buyer and Cypress. Milestones only: document uploads and field edits stay in the closing itself, where the detail belongs. Before this, a car mid-sale looked dormant everywhere outside the post-sale hub.
- Add what's missing without leaving the deal. Deal Setup already let a seller add a bank account or lienholder inline; the same quick-add now covers pickup locations (seller side) and title addresses and transporters (buyer side). Each creates the real saved entity — the one Settings manages — and selects it on the sheet immediately. A Cypress manager adding a buyer-side address creates it in the *buyer's* library, never the seller's.
- Addresses start with the ZIP. The new location and title-address forms lead with the ZIP code and fill in city and state from it, the same decoder the registration wizard uses.
- Drop photos straight onto the vehicle. The media grid now accepts files dragged from the desktop — several at once, uploading in parallel with the same per-file progress and error states as the picker (which has always accepted multi-select). Unsupported files in a dropped batch are skipped rather than failing as broken tiles.
Fixed
- Errors say which field is wrong. Saving a bank account with a 13-digit routing number reported `[object Object]`; every settings entity — bank accounts, lienholders, pickup locations, title addresses, transporters, labels, pricing rules, custom fields, and the admin org document/checklist routes — now names the field and the rule. The bank form also checks the routing number before submitting, since the mistake it catches is usually an account number in the wrong box.
- "Last Update" on the Post-Sale hub tracks the closing. The column read the *vehicle's* last notice — and closings wrote none, so an active deal showed "Vehicle listed · 67d ago" forever. It now reflects the closing's own latest movement, and sorting by it puts genuinely moving deals on top.
v1.9.7
2026-08-17Chains get their own trigger, and priorities get a save button
A day of living with v1.9.6's automation ordering surfaced the rough edges: chained flows had no honest trigger to wear, priority edits saved silently with no feedback, and the chain relationships were invisible unless you already knew where to look.
Added
- The "From Another Flow" trigger. A chained flow no longer borrows Manual or keeps an event trigger that double-fires: the new trigger runs a flow *exclusively* when another flow's Run Flow node calls it — no domain event ever emits it. The Run Flow picker now groups these dedicated chain targets first and warns when you point a chain at an event-triggered flow (which will also keep firing on its own events). Chained runs are logged with this provenance.
- Priority edits stage, then save — like the fleet grid. Editing priorities in the Automations hub now highlights the changed fields and raises the familiar dark bulk bar ("2 priorities edited — Save 2 edits / Discard"), applying everything at once with a confirmation toast, instead of silently PATCHing on every blur.
- Execution order in the flow editor. The flow side panel shows the ranked list of live flows sharing this flow's trigger — the exact order the worker runs them — with a Manage button into the priority modal. The topbar's "shares trigger ×N" chip is now clickable and opens the same modal outside the publish path.
- Chain relations you can read. Hub rows now say it in words on their own lines — "called by Listing price" on the child, "then runs Chain flow demo" on the parent — instead of cryptic badges colliding with the priority column.
Fixed
- Bulk action bars are actually centered. The fleet bar (and the shared grid bar behind buyer-hub, post-sale, promotions, update-requests, and archived vehicles) positioned itself with `left: 50%` + a translate that framer-motion's entry animation clobbers, leaving the bar half its width to the right. All bars now use the auto-margin centering the seller-hub bar already pioneered.
v1.9.6
2026-08-16The VIN sells the car, and flows stop racing in the dark
The business was blunt: without the VIN on the page, buyers don't buy. And in Automations, two things had quietly become unworkable — the switch node's branches couldn't be wired to anything until a page reload, and flows sharing a trigger ran in whatever order the database felt like, with no way to even see which flow fires on what.
Added
- The VIN is public. Every buyer-facing surface — the open marketplace, Direct Link pages, storefront pages, and their browse cards — now shows the vehicle's VIN. This deliberately reverses the v1.7.3 seller-anonymization stance: a public VIN can be cross-referenced to the seller's own listings, and the business has decided that being able to sell outweighs that.
- A Trigger column in the Automations hub. Every flow now shows what fires it — with the cron for schedules, the watched fields for update triggers — plus an amber ×N badge when other active flows share the trigger, and a filter to slice the list by trigger.
- Execution priority. Flows that share a trigger now run in a deterministic, user-controlled order: a `priority` number on each flow (lower runs first, ties by age), editable inline in the hub. The worker orders matching flows by it instead of leaving the order to the database.
- Flow chains — the Run Flow node. A flow can now explicitly queue another flow as its next act, carrying the vehicle along. Each chained flow gets its own run and audit trail; chains stop at 3 hops so an accidental A→B→A loop terminates on its own; publishing refuses a node with no target or one pointed at its own flow. Chained flows wear a "chained" badge in the hub.
- A publish-time racing check. Publishing a flow whose trigger is already claimed by other live flows now pauses on a modal showing exactly who else fires on that event and in what order — with priorities editable right there — before asking "Publish anyway". The canvas topbar carries a passive "shares trigger ×N" chip with the same knowledge.
Fixed
- Switch-node branches can be connected the moment a case is added. React Flow only measures a node's connection handles at mount, and the switch node grows its handles at runtime without changing size — so every newly added case handle was invisible to the canvas until a full page reload. The node now tells the canvas to re-measure whenever its handle set changes.
- Renaming the switch default branch no longer severs its edge. The default branch's free-text label doubled as its edge id, so typing in the field orphaned the drawn edge one keystroke at a time. The id is now frozen; the text is just a label.
- Proximity-connect no longer wires switch branches to the wrong path. Dragging a node near a switch or branch auto-created an edge with no branch chosen, which the executor silently routed as "default". Multi-output nodes now require drawing the edge from the branch you mean.
v1.9.5
2026-08-11The commission is a gate, not a formality
A test sale produced a commission invoice that was born "Paid" — no card was ever charged — and the closing marched on regardless. Two independent defects lined up: the closing completed its fee step at invoice *creation*, so payment was never actually a gate; and Lago auto-marks a one-off invoice "succeeded" when the customer has no payment provider linked, then delivers that verdict by webhook after the fact. Fixing the pair, and then walking the same closing as the buyer, the seller and the manager, turned into a broader pass on who must pay before what — and who gets to see whose money.
Added
- Get Invoice PDF, on demand. An invoice whose PDF was never stored — older sales that predate the PDF webhook, or a failed generation job — now offers a button in the Billing panel that pulls the document from Lago through the same worker pipeline, for the seller as well as the manager.
- The ACH batch billing guide ([docs/product_features/ach-billing-guide.md](docs/product_features/ach-billing-guide.md)): how the two collection modes differ, what the business account sets up (instant bank verification, the debit mandate), what only the CM controls (credit limit, force conciliation, settle, record payment), the automatic 14th/28th cycle, and the full sandbox test recipe.
Fixed
- A closing no longer advances on an unpaid commission. For an online (card) seller, step 1 now stays pending — with the org's payment window as its due date — and the Bill of Sale stays locked until the invoice is actually PAID, arriving by any road: Stripe checkout webhook, embedded-checkout verification, the Lago webhook, or a manager recording an out-of-band payment. The post-sale panel already had the "commission payment required" card built for this state; the backend now produces it. ACH-enabled sellers are untouched — their commission defers and the closing advances immediately, which is the point of ACH.
- Invoices can no longer be born "Paid". The Lago webhook re-reads the invoice's current payment status before trusting a `succeeded` event, so the stale auto-succeed verdict invalidates itself while a real Stripe payment passes through. Belt and braces: every Lago customer in every environment is now actually linked to its Stripe payment provider, which stops the auto-succeed at the source.
- The invoice line reads like a document, not a database row — "Standard Remarketing — 2025 Mercedes-Benz GLE-Class — VIN 4JG… — sale price $55,860" where it used to print internal record ids.
- An abandoned bank re-connect no longer kills a working ACH setup. Opening the Financial Connections modal downgraded the org to SETUP_PENDING before anything happened; closing the modal left it there — silently excluded from every collection sweep, which is exactly how Seller Credit Union's working mandate went dark. Re-setup now leaves an enabled org alone until the new bank actually confirms, and disconnecting clears the stored mandate too.
- A deferred commission stops offering a PDF that cannot exist. The invoice document is issued when the ACH batch closes; until then the step says so instead of showing a button that did nothing.
- The buyer sees the deal, not the seller's economics. The closing header no longer shows the buyer the seller's fee and net proceeds (they see the sales channel), and the commission step — still visible, since it gates the closing — no longer exposes amounts, the invoice, its PDF or a pay button to the buyer.
- Step badges name who acts. Payment was badged "Cypress", as were the seller's own uploads — both parties were being told to wait for someone else. Badges now come from each step's actual actor: Buyer signs and pays, the Seller uploads and ships — and on self-serve deals the seller's steps keep the "{org}-run" marker that says their team also runs the review.
- The commission step belongs to the seller. With payment as the gate, "Commission Fee — Cypress" was the wrong story twice over: the seller is the one who must act, and a step that blocks the whole closing was still labeled *non-blocking* right next to "next step is blocked until this invoice is paid". The step is now the seller's turn, its action is "Pay Invoice", and the paywall path drops the non-blocking marker the ACH path legitimately keeps.
v1.9.2
2026-08-11Notifications that go somewhere
A commission invoice email arrived with a "View in billing" button that went nowhere: its link was `/billing`, a path with no host, which a mail client can't open. An audit of the notification system showed this was the rule, not the exception — every notification travels through one worker that passes its link to the email template verbatim, and roughly sixty call sites across billing, closing, offers and the fleet were handing it app-relative paths. The same audit explained why in-app notifications read like announcements instead of actions: the payload always carried a destination, but the in-app channel was never told about it.
Fixed
- Email buttons now carry full URLs. Link absolutization now happens once, centrally, in the worker that triggers every notification: relative paths get the environment's real host prepended, absolute ones (like presigned download links) pass through untouched. Every producer — billing invoices, ACH settlements and failures, every closing step, offers, listings, due-diligence and inventory alerts, buyer-alert digests and their per-vehicle cards — is covered without touching each one, and a missing host configuration now logs loudly instead of silently shipping broken links.
- In-app notifications are actionable. Every workflow's in-app step now carries the notification's title (rows no longer fall back to the literal "Notification"), makes the row click navigate to the notification's destination, and shows a "View details" button. Same-origin links open in-app without a full page reload; a notification with no destination simply shows no button instead of a dead one.
- Staging campaign emails no longer touch production. The promotion campaign sender read a base URL from an env var nothing sets, then fell back to the hardcoded production domain — so a staging campaign's click tracking, open pixel and unsubscribe links all acted against production. It now uses the same per-environment variable as everything else, and refuses to send rather than guess.
- No more localhost links leaving the platform. Emailed inventory exports built their Direct Link column against a `localhost` fallback, and the admin notification test panels hardcoded `localhost` into test emails; exports now fail the flow step loudly when the host is unconfigured, and test sends build links from the environment they're fired from.
- Notifications that had no destination got one. Rejected registrations, offers withdrawn by a paused listing, automation-flow notifications and failed Market History exports sent emails whose buttons rendered with an empty link; each now points somewhere sensible.
- The email footer's "Manage notifications" goes to notification preferences — it used to reuse the notification's own call-to-action link, taking you to an offer or a vehicle instead of your profile.
- "Copy from company address" 404'd for a Cypress Manager working on an org's behalf — the hook always read the CM's own session org, which that API deliberately refuses. It now reads the target organization through the admin route, on both the title-addresses and transporters tabs.
- Address entry follows one convention everywhere: ZIP first, with city and state derived automatically — title addresses, lenders, the org profile, the legacy gate-pass pickup form and the admin customer editor all join the forms that already worked this way.
v1.9.1
2026-08-11Who may do what: roles said out loud
A Cypress Manager selected three DRAFT vehicles and was told only Fleet Managers could submit them — while the same person could submit them one at a time. Pulling that thread surfaced a pattern: nineteen routes tested "the role is CYPRESS_MANAGER or ADMIN" and called the result *platform staff*, but ADMIN is an org-scoped role — a customer organization's admin passed every one of those gates. This release makes the permission model say what it means, and makes several fleet actions genuinely seller self-service.
Added
- Seller self-service on the fleet. Marketplace launch (single and bulk), delisting, applying pricing rules, bulk label edits and auction rules are now available to Fleet Managers and org admins for their own inventory — previously these were Cypress-only, and Fleet Managers were locked out entirely. Organization scoping keeps every seller inside their own fleet; Cypress staff keep cross-org reach. The fleet page and Seller Hub bulk bars show the matching buttons in both views.
Fixed
- Bulk "Submit for Review" refused Cypress Managers while the single-vehicle path allowed them — the same person, the same vehicles, a different answer at three than at one. Cypress staff now submit on the seller's behalf here too, as everywhere else.
- "Platform staff" now actually means platform staff. Every hand-rolled role check that conflated a customer org's admin with Cypress staff was replaced with the canonical test (ROOT organization + staff role). Concretely: a customer org admin can no longer act on other organizations' buyer-hub offers (the query widened to every org — a real cross-tenant leak), record manual invoice payments, or appear in activity logs and billing views as a CYPRESS_MANAGER.
- The review side stays the review side. Run Valuation, Set Listing Price, Submit for Approval and Return to Review remain Cypress actions — the seller asserts readiness, Cypress prices and approves, exactly as before.
v1.9.0
2026-08-10Closing documents: designed, generated, signed
Closings used to run on two hardcoded upload slots — a Bill of Sale and a gate pass, each a PDF somebody produced elsewhere. This release replaces that with a document platform: Cypress and each organization design their closing paperwork on a canvas, the deal's facts live in one place both sides confirm, documents generate themselves from those facts, signatures are collected in the declared order — electronically through the platform or in ink at the yard — and the checklist knows the difference between where a document is *born* and the step by which it must be *fully signed*.
Added
- The canvas document designer. Fields, multi-row blocks, signature areas, images, boxes, lines and vehicle damage diagrams, placed on US Letter or A4 — or on your own uploaded PDF background. Every field binds to the deal through a three-part palette: Cypress data (filled automatically from the vehicle and the sale), saved records (filled when a pickup location, bank account, lienholder, title address or transporter is picked in Deal Setup), and your organization's own custom fields. Publishing creates an immutable version; documents generated from it point at that exact version forever. Cypress ships base templates (Bill of Sale, Gate Pass, Delivery Receipt); an organization can copy and make them its own, and a Cypress manager edits the base ones from the admin section.
- Deal Setup — the deal's single source of facts. A card above the closing steps where the seller side picks where the vehicle is (including "Vehicle location" straight off the vehicle) and where the money goes — with inline "add a bank account / lienholder" — and the buyer side picks where titles go and who transports the vehicle. Selections copy the record's values onto the deal sheet at that moment, so a later edit to a saved record can never silently rewrite a signed deal; every later change is an audited amendment. Both sides confirm, and that confirmation is what unlocks the Bill of Sale step. In-flight closings can adopt Deal Setup without re-billing.
- Generate from template. The step card offers the documents its checklist asks for. Before generating: a data form showing every bound field with its current value — missing facts the generating side owns (the plate, a pickup contact, a custom field) are answered right there and saved to the deal sheet; fields the template marked "can be corrected" take a per-document override, recorded but never touching the sheet. A preview shows the exact bytes; attaching travels the same path an upload does, with full provenance. When the deal's facts change — or a newer template version is published — the document says so and offers to regenerate.
- Multi-party e-signatures, in order. Generating a document creates its signature requests in the same action — one submission, one signer per party the designer declared, boxes at the designer's exact rectangles. Signing order is enforced: each party's link goes live when it's their turn; sellers and buyers sign from within Cypress, external parties (a transporter, a notary) are emailed theirs. Statuses land on the step as they happen, and when the last signer finishes, the fully-executed PDF — signatures burned in — replaces the clean render everywhere the document is shown. On the Bill of Sale, approval waits for the seller side and forwards the same submission to the buyer; the buyer's signature completes their step and unlocks payment.
- Wet ink, without losing the thread. "Upload signed copy" attaches the printed-and-ink-signed scan as the same document — provenance intact, remaining electronic signatures still collectable — and is accepted even after a step completed, because a gate pass is verified before release and signed by the transporter at the yard. A signed copy landing on a completed step is the record of what was released, not a new review item.
- The closing document checklist. Which documents each step asks for is now per-organization configuration: slots that are upload-only, filled by any template of a kind, or pinned to one specific template; required slots block their step's completion, and a Cypress manager can waive any slot for one closing with a mandatory, audited reason. Each closing snapshots the checklist at creation, so config edits never change a running deal. The designer edits the checklist too — "Used in closing step" pins the template as a slot.
- "Must be fully signed by step X" — the execution checkpoint. A document is born on one step and finished later. A checklist slot can now name the step that must not complete until the document is fully executed — every required electronic signer done, and the ink scan uploaded when the template has wet signature areas. The blocked step shows what it's waiting for up front, with the fix inline: upload the signed copy right there, or waive it (CM, with reason).
- The gate pass flow, reworked. The pickup address comes from Deal Setup — the step stops asking a second time. One Verify action accepts the paper and completes the step. Signatures sit below the document; the seller e-signs before verification, the transporter signs at release, and the scan comes back to the verified step.
Fixed
- The buyer side of Deal Setup never offered a transporter selector — the records loaded, the API accepted the choice, and no dropdown rendered. And picking a transporter now also answers the gate pass's "released to" fields, which bind the asked transporter facts; before, a buyer with perfect records still printed a blank RELEASED TO block.
- "Asked" fields never asked. The plate and the transporter lines were cataloged as "asked at Gate Pass", and nothing anywhere prompted for them. The generate form is now where the asking happens — and an answered fact stays editable on regeneration, because regenerating exists precisely to fix earlier answers.
- Approving a canvas-signed Bill of Sale created a second DocuSeal submission, handing the buyer an unsigned copy of a document they were already signing. The buyer now signs the same submission the seller did.
- The gate pass step stacked two review mechanisms — per-document Accept/Reject plus Verify. Verify is the acceptance.
- The canvas preview clipped long values the renderer would wrap; both now wrap alike.
- Payment recipient on generated Bills of Sale is derived from Deal Setup's money selection instead of asking the seller to restate it, and the approval box explains that it drives the payment step.
- The signatures panel showed every signer a live-looking link regardless of turn; links now unlock in signing order, matching what the signing service actually enforces.
v1.8.0
2026-08-06Inventory reports by email, and a vehicle that can sell twice
Two halves. Automation flows have always acted on one vehicle at a time; this adds the nodes a CM needs to act on a *set* — collect a channel-filtered slice of inventory, put it in a spreadsheet, email it out on a schedule. And a vehicle whose first sale fell through could never be sold again through Post-Sale: the second deal was rejected by a database constraint, the error was swallowed at every call site, and the buyer was told "Purchase Confirmed!" for a sale that existed nowhere.
Added
- Export File — builds a CSV or XLSX from whatever a Fetch Vehicles node upstream collected: a configurable column set (VIN, status, pricing, days listed, and — new — the vehicle's Direct Link URL and active-link count), and a templated filename (`{{count}}`, `{{date}}`). There is no size limit on the file itself; the 8 MB cap lives on Send Email, where an oversized attachment blocks the send outright rather than mailing a report without its report.
- Send Email — mails that export to explicit addresses, not roles: a subject/body with `{{count}}`, `{{filename}}`, `{{orgName}}`, `{{flowName}}` tokens, and an "attach the file" toggle. Deliberately fail-loud throughout — an empty recipient list, a malformed address, an oversized attachment, or an empty batch with "skip when empty" on all end the step SKIPPED, with the reason stated on the step and the run closing PARTIAL, never a silent SUCCESS.
- The batch lane. Setting Fetch Vehicles to *collect* mode stops its usual per-vehicle fan-out and instead hands the whole matched set to Export File and Send Email as one shared batch — the only way those two nodes, or any future node built to act on a set, can run. The validator enforces the pairing, and publishing is refused if it fails — on the server, not only on the canvas, so a hand-built flow is held to the same rule as an AI-built one. An aggregate node without a collect-mode Fetch Vehicles upstream is rejected, and so is a per-vehicle node placed *after* one, since collect mode carries no vehicle for it to read. That pairing is the *only* thing publishing refuses; every other thing the validator has to say stays advisory on the canvas, so a node parked unwired off to one side — ordinary working state — never blocks a republish. Saving a draft stays permissive: work in progress is allowed to be half-wired.
- Fetch Vehicles gains an organization list and a sales-channel filter. A report can now span several customers by id, and can be narrowed to vehicles actually reachable through Direct Links (P2P) or a Storefront. Channel filtering resolves each requested org's real grant rather than trusting the platform default — an org holding P2P but not Storefront contributes only its P2P vehicles, never both and never neither.
- A flow that picks its own vehicles can be run on demand. Pressing Run on a flow containing a Fetch Vehicles node no longer asks which vehicles to run it against — that question is the node's to answer. The modal offers the trigger, a dry-run/live choice, and Launch, and executes exactly what the schedule would. Flows that genuinely need a vehicle keep the picker.
- `pnpm backfill:stranded-closings` recovers sales the constraint below had already rejected. Two idempotent passes: open a closing for any accepted offer that lacks one, then fire any open closing's FEE step that never went off. The second pass is what recovers a run interrupted between the two — once a closing exists its offer is no longer stranded, so the first pass can never see it again. It refuses to start under `--apply` without `LAGO_API_URL`, because the FEE step's billing call would otherwise hang rather than fail.
Fixed
- A flow triggered without a specific vehicle could not complete a run — at all. Every `SCHEDULE`-triggered flow and every org-wide `MANUAL` run (no vehicle picked) has no vehicle to attach to its steps, and the run context passed the empty string down where the trigger node's own audit row writes `vehicleId` — a foreign key column. Postgres has no `Vehicle` row with `id: ''`, so it rejected the insert and the run died before Fetch Vehicles, Export File or Send Email ever executed. One of the two places that write a step already guarded this value with `|| undefined`; the other, general-purpose one — the path every node takes, including the trigger itself — did not. Found by task-15 end-to-end verification running the real, unmodified `processAutomationRunJob` exactly as `/api/cron/automation-schedule` does; invisible to the unit suite because it mocks the database layer entirely, so the foreign key was never actually written. Both paths now agree, and a re-run of the identical scenario completes clean: 97 vehicles fetched, exported and (loudly, correctly) failed to email — see below.
- A successful Export File or Send Email step showed nothing in the run-detail drawer. Both nodes compute a human-readable summary (row count and filename; recipient list and attachment count) but the step-persistence path only stored it when a node returned a vehicle-field diff, which neither new node has — they act on a set, not a single vehicle. A CM reviewing a completed run saw status and duration but not what was actually exported or emailed. Successful steps with no vehicle diff now persist `{ summary }`; steps that update a vehicle are unaffected — they persist the identical diff they always did.
- A run with a blocked step still closed green. A blocked step stops its branch by returning, not by throwing, and the worker only inspected thrown errors — so "no recipient", "attachments too large", "skip when empty" and "No organization selected" each produced a SUCCESS run with no email sent. Such runs now close PARTIAL, with the blocked steps summarized on the run, and a dry run records them too. This is also what would have hidden the Novu attachment-storage failure described under Deploy.
- Every business-rule guard in the engine crashed the run it was meant to explain. When a guard stopped a node, the executor wrote the step's status as `BLOCKED` — a value the `FlowRunStepStatus` column has never accepted. The database rejected the write, and because that write sits outside the executor's own error handling, the rejection escaped the whole traversal: the run closed FAILED carrying a database validation error instead of the plain sentence the guard had prepared. Every one of the engine's seventeen guards was affected, not just the new nodes' — "No organization selected" and "Skip when empty" among them — and not one of them has ever recorded a step, in any environment, since the automation engine shipped in 1.0.0. A deliberate stop is now recorded as SKIPPED, the status the column already had and the engine had never used, with the reason on the step exactly as before; the run still closes PARTIAL. The run-detail drawer reads such a step as a stated stop rather than an error. No migration. It went unseen for a year because every unit test in the repository substitutes the persistence layer, so the column's actual constraint was never exercised. There is now a test that reads the enum out of the schema and asserts that every status the executor can emit is a member of it, and the fix was proven by running a real flow through the real worker against Postgres.
- `{{orgName}}` rendered as "global". The token resolved from the flow's own scope. Every flow the Automation Hub creates is global-scoped, so the operator's daily report reached its recipient titled "…for global". Organization selection lives on the Fetch Vehicles node, and the token now names the organizations that node actually read — joined with commas when a report spans several.
- A Storefront filter listed inventory no storefront serves. Selecting the Storefront channel checked only that the organization *held* the Storefront grant, not that its storefront was published. An org staging inventory onto an unpublished storefront — a supported state — had that inventory exported and emailed to a partner as available stock, while the Seller Hub showed nothing for the same vehicles. The filter now applies the same liveness rule as every other place that reads the column.
- A spreadsheet cell could run as a formula. A make or model imported from a dealer CSV starting with `=`, `+`, `-` or `@` was written to the export verbatim and evaluated when the recipient opened it. This is the first export that leaves the platform automatically, to third-party addresses; such cells are now neutralized. Genuine negative numbers are untouched.
- A GLOBAL-scoped flow silently matched nothing and reported SUCCESS. The run route substituted the literal string `'global'` for "no organization," which then flowed into Fetch Vehicles as an actual `organizationId` — a value no vehicle ever has. Zero rows matched, and the run still closed out green. Fetch Vehicles now recognizes the placeholder and blocks with "No organization selected," instead of quietly doing nothing.
- The Fetch Vehicles preview answered a different question than the node asks. It sent only the status, make and search filters to a platform-wide endpoint, ignoring the organization and channel selections sitting directly above it — so a node scoped to one customer's Direct Links inventory previewed as 467 vehicles where it would fetch 96. The preview now runs the node's own query, through the same shared builder the worker uses, so the two cannot disagree.
- Deduplication never deduplicated. "Skip vehicles this flow already processed" was handed a run id where the flow id belonged, so its lookup matched nothing and excluded nobody — for every flow, since the option shipped in 1.1.0, while the panel stated the opposite. It works now. It is also correctly disabled in collect mode, where there are no per-vehicle records to deduplicate against and the promise could never have been kept.
Post-sale & billing
- A vehicle whose sale fell through could never be sold again. `ClosingProcess.vehicleId` carried a lifetime UNIQUE, so a vehicle got one closing ever. When a cancelled deal's vehicle was relisted and sold, the second closing's INSERT failed on the constraint — and all five call sites wrapped that call in `.catch(() => {})`. The buyer saw "Purchase Confirmed!", and the deal appeared in Post-Sale for nobody: not the seller, not the buyer, not Cypress. The constraint is now a partial unique index over *open* closings only, which keeps the invariant that matters — never two open closings on one vehicle — while letting a vehicle's history accumulate. `COMPLETED` is deliberately outside that set, because a delivered sale can be reverted and the vehicle relisted (1.3.1), and including it would reintroduce the same defect on that path.
- A failed closing is now reported rather than discarded. Those five swallowed failures are what turned a constraint violation into a buyer-facing confirmation for a sale that did not exist. Bootstrapping still does not rethrow — the offer is genuinely accepted by that point — but the failure now reaches Sentry and the vehicle's audit trail instead of vanishing.
- Post-sale read the wrong sale. With more than one closing per vehicle now possible, a `findFirst` keyed on the vehicle with no ordering returns an arbitrary row — in practice the oldest, which is the cancelled deal. Every vehicle-keyed read now takes the most recent. Reads already narrowed to the open closing are untouched: the partial index makes those at most one row by construction.
- A re-sale was never billed. The FEE step skipped invoicing whenever the vehicle already had a commission invoice — including one from a previous, cancelled sale — so the second sale's closing parked on step one with nothing billed. The guard now keys on the sale, not the vehicle: `CommissionInvoice` carries the offer it belongs to, backfilled for existing rows.
- Both commission invoice emails promised an attachment and sent none. Each said "Please find the invoice PDF attached" while carrying only a storage key, and no code path anywhere built the attachment — the earlier of the two fires before the PDF has even been generated, so it never could have. The worker now reads the file out of storage at send time, so the bytes never sit in a queued job payload or get re-carried on a retry, and a missing or oversized file costs that attachment rather than the whole email. Both messages compose from one function — they had already drifted to the same false claim with different data — and both now name the commission scheme, which matters once a seller is billed differently on Direct Links than on the marketplace.
v1.7.8
2026-08-05Cypress was its own biggest customer
The retention report named **Cypress** as a buyer of 76 vehicles. It never bought any of them.
Fixed
- 84 accepted offers named the wrong buyer. A bulk import in late July attributed 76 offers to the Cypress ROOT organization — the platform itself — and a further 8 to an individual's account rather than the dealership they were buying for. The organization then appeared as the largest customer on the retention report, while every real dealer's purchase count ran short by exactly the amount it had absorbed. Fox Rent A Car showed 26 purchases against 69 in the sales book. The book names the actual buyer for each, so all 84 have been repointed. No accepted offer in production names a ROOT organization any longer.
- The retention report can no longer count Cypress as a customer. Correcting the records was not enough — the query would have said it again after the next import. The buy side now excludes ROOT-type organizations outright. The sell side deliberately does not: no ROOT organization owns inventory, and a blanket filter there would silently drop real sales if one ever did.
Added
- `--fix-buyers` and `--create-buyer-orgs` on the sales-book script. Both are opt-in and dry-run by default, matching `--reconcile` and `--create-missing`. Five dealers the book names had no organization in production and were created with a name and nothing else, at `ONBOARDING` status, so they read as incomplete rather than as vetted customers.
- A buyer alias table. The book records dealers under trading names, the platform under legal ones. `BUYER_ALIASES` maps between them, and every entry is confirmed by hand: "Velocity Motors" surfaced only because creating it collided on the slug `velocity-motors`, already held by "Velocity Auto Sales LLC" — the same business. A slug collision now reports the clash and creates nothing, because it almost always means the dealer is already on file under another spelling.
v1.7.7
2026-08-05Hotfix: the Analytics page crashed at every stage
Fixed
- `/admin/analytics` threw `Cannot read properties of null (reading 'localeCompare')` and rendered nothing. 1.7.6 widened five queries to the newer sale stages. Four of them also required the sale to have a date; the Top Customers query did not. A vehicle carrying a sale market but neither `saleCompletedAt` nor `statusEnteredAt` therefore produced `to_char(null)` — a null month — and sorting the cohort rows by month died on it, taking the whole page down. The date a sale is reported on, and the check that it has one at all, are now single shared SQL fragments used by every stage-aware query, so the two can no longer drift apart. The client retention queries carried the same latent fault — they bucketed by `saleCompletedAt` alone under a WHERE that no longer required it — and are fixed in the same change. The month sort is additionally null-tolerant. The SQL guarantees a month; a missing bar is a better failure than a blank page.
v1.7.6
2026-08-05One definition of a sale, across the whole dashboard
1.7.5 put a stage switch on two tabs. Two tabs on one page could therefore report different numbers, which is the thing the switch exists to prevent.
Changed
- The stage switch is now a single control beside the tab bar, driving every tab that has a sale definition — Monthly KPIs, Sales, Top Customers, Retention, Path to $100M and the time-lapse — plus the page header, so the headline can never disagree with the tab beneath it. Everything still defaults to *DD complete*, so the dashboard opens on the numbers it has always shown.
- Cash-Basis Export and the Action Funnel deliberately ignore it, and say so on screen. The first reports cash actually received against settled invoices; the second reads the audit log. Neither has a sale definition to vary — a deal still in due diligence has no settled invoice by construction — and offering a control that silently does nothing would be worse than offering none.
Fixed
- Wider readings were understating GMV. `finalSalePrice` is only stamped at closing, so every committed deal contributed $0 to the *All* and *Offer accepted* levels. GMV now falls back to the accepted offer amount wherever that field is absent.
- Unique sellers and buyers could have been double-counted. Monthly KPIs, Top Customers and Retention report DISTINCT counts, so an organization active at two stages would be counted twice if the levels were summed. Each stage is queried in full rather than aggregated from parts. All three resolve server-side in one pass, so switching re-renders locally instead of costing a round trip.
v1.7.5
2026-08-05Sale markets, release history, and a sales time-lapse
A pitch-deck slide animating every sale onto a US map turned out to be two datasets stitched together — and chasing its data found a $2.03M gap between the operator's sales book and what the platform reports.
Added
- A sales time-lapse in Analytics: every sale appearing on a US map, market by market, over the real timeline. Markets are named and positioned by city — Cypress sells into metros, and a bubble at Arizona's centroid is not Phoenix. Bubble fill tracks cumulative vehicles, the ring tracks cumulative GMV, and a pulse marks what sold that day. Release markers use real dates from the changelog.
- A release history tab: 42 releases and 449 changes parsed from `CHANGELOG.md`, each expanding to its full content. The history is generated into source rather than read from disk at runtime, with a test that fails if it falls behind the markdown, so it cannot silently go stale.
- A three-stage switch on the Sales report (and on the time-lapse) — *All*, *Offer accepted*, *DD complete*. The stages are cumulative, not alternatives: every completed sale was an accepted offer first, so each level is a strict subset of the one before it. The report defaults to `DD complete`, its historical definition, so its headline numbers are unchanged; the wider readings are opt-in and each button carries its own count, making the gap between definitions visible rather than something to infer.
- `Vehicle.saleState` — the market a sale closed in. Deliberately not `locationState`, which is where the vehicle physically sits: on the historical book the two disagree on 17% of rows carrying both, because a unit standing in AZ routinely sells into UT. Sales-by-market reporting reads the new field; pickup and logistics keep reading the old one.
- `pnpm backfill:sale-markets` fills that field, and the real sale date, from a TSV export of the operator's sales book. Fills only nulls, so it is safe to re-run. The book is not committed to the repository — it holds VINs, buyer names and prices for real transactions.
Fixed
- The Sales report was understating the business. Its filter is `status = SOLD AND saleCompletedAt IS NOT NULL`, which excludes every deal whose due diligence is still open. Against the book, production reports 128 of 200 sales — $3.49M of $5.52M. The stage switch above makes the rest visible without redefining what a closed sale is.
v1.7.4
2026-08-05Vehicle fields that can be cleared, and engine fields that save at all
The sibling defects 1.7.3 flagged and deferred. One of them turned out to be worse than described.
Fixed
- Ten fields on the vehicle form could be changed but never emptied — `bodyClass`, `driveType`, `fuelType`, `transmission`, exterior color, and all five location fields. Each sent `undefined` for a cleared input, and `JSON.stringify` drops undefined keys, so the field never reached the PATCH, the endpoint's `field in body` guard was false, and the previous value survived the save. This is the same defect fixed for `trim` in 1.7.3.
- The engine, displacement and horsepower inputs never saved anything. Worse than the above, and missed when 1.7.3 described the remaining fields as carrying "the identical defect" — they do not. The form calls these three `engine`, `displacement` and `horsepower`; the endpoint's field lists know `engineCylinders`, `engineDisplacement` and `engineHp`, and only `color` was ever renamed on the way out. Anything sent under a name the endpoint does not recognize is discarded without complaint, so every edit to those three was lost on save. Displacement and horsepower were never *loaded* either, so they rendered blank no matter what the vehicle knew — inputs that could neither show a value nor keep one. Both directions are now wired. Both rules — what a cleared field serializes to, and what each field is called at the boundary — now live in one pure, tested module instead of inline in a 4,000-line component. That is what makes them testable: the original bug sat inside a closure where no test could reach it, which is exactly why it survived a release after being identified. Identity fields (`year`, `make`, `model`) still fall back to the stored vehicle rather than being erasable, and numeric fields keep their existing behavior — clearing a price is a separate decision from clearing a description.
v1.7.3
2026-08-05Vehicle identity: trim recovered from the model column, and a specifications card with something in it
Two halves of the same complaint — the platform knew less about a vehicle than it could have. Sellers were handing us the trim inside the model column and we threw it away; the VIN decode was handing us engine and body data and we never wrote it down.
Added
- Trim is lifted out of the model column at import. Dealer files routinely carry it there — `Camry SE Nightshade`, `F150 XLT SuperCrew`, `Grand Cherokee Laredo`. VIN decode is authoritative for the model, so everything alongside it used to be discarded. The decoded model is now matched inside the CSV string ignoring case and punctuation (`F150` matches `F-150`) and whatever remains becomes the trim, kept verbatim so a seller's own vocabulary survives — including the parts no trim dictionary would recognize, which is exactly what a dictionary-based approach loses. A trim the seller supplied is never overwritten, judged per row, so a Trim column that is filled for some rows and blank for others still gets filled where it is blank. The split appears in the import review under the same `CLEANED` badge as `blk → Black`, with one checked-by-default checkbox to decline it for the whole file. Declining leaves Trim empty; the model is corrected from the VIN either way. Review and import cannot disagree: both run the same function over the same offline VIN database. That is also why the split is gated on that offline decode rather than the full decoder chain — an interactive review cannot make hundreds of network calls, so a split that depended on them would promise the reviewer something the import would not deliver.
- The marketplace specifications card gains Trim, Vehicle type and VIN. Trim was previously visible only mashed into the page title. VIN is shown to signed-in buyers only — on the public marketplace and public storefronts a VIN can be pivoted to the seller's own listing elsewhere, which would undo buyer-facing seller anonymization.
Fixed
- The import never stored most of what the VIN decode returned. The worker built its decoded object with eight keys and wrote four specification columns, so `engineCylinders`, `engineDisplacement`, `engineHp`, `vehicleType`, `plantCity` and `plantCountry` were dropped even when the decoder supplied them. The consequence was visible on every listing: Engine, Transmission and Interior color were null on all 316 listed vehicles, so three rows of the specifications card rendered nothing, on every vehicle, since the card was built. A related trap is now handled in one place: NHTSA answers `""` rather than `null` for a value it does not hold, and storing those blanks defeats the card's own empty-value check.
- An existing trim could not be cleared. The vehicle editor sent `undefined` for an emptied field, and `JSON.stringify` drops undefined keys — so the field never reached the request, the API's `field in body` guard was false, and the old value survived the save. A trim could be changed but never removed. Ten sibling fields on the same form carry the identical defect and are not fixed here; they are tracked separately.
v1.7.2
2026-08-03Hotfix: three broken redirects
Fixed
- The customer email-confirmation link landed on `https://localhost:6200/s/confirm` — on staging *and* production. `/api/public/consumers/confirm` redirected with `new URL('/s/confirm?…', request.url)`, the same proxy-unsafe idiom as below. The account was genuinely confirmed (the write happens before the redirect), but the person saw a browser error and had every reason to believe it had failed — and the obvious recovery, clicking the link again, reports `invalid`, because the token is single-use and was already spent. The URL *inside* the email was always correct: it is built from `NEXT_PUBLIC_APP_URL`, which is baked correctly per environment (`app.getcypress.ai` in production, `staging.app.getcypress.ai` in staging). Only the hop after the click was wrong. The route now uses `sameOriginRedirect` and has tests, which it previously had none of.
- Every visitor arriving at sign-in on `www.getcypress.ai` got a 500. The canonical-host hop added in 1.7.1 — which exists precisely so an apex visitor is moved to `app.getcypress.ai` before the host-only session cookie is written — was built on `Response.redirect(target, 307)`. That static returns a response whose headers carry the immutable guard, and Next.js's route wrapper writes to the headers of whatever a handler returns, so the hop itself died as `TypeError: immutable`. It now constructs the response directly, leaving the headers mutable. No unit test could have caught it as written: outside Next.js nothing mutates the response, so `Response.redirect` behaves correctly and all four existing assertions passed. There is now a test that asserts the returned headers are writable, which is the property that actually differed.
- Completing a sign-in redirected the browser to `https://localhost:6200/dashboard` on staging and production. `/api/auth/post-login` built its redirect with `NextResponse.redirect(new URL(target, req.url))`, and behind Railway's proxy a Node-runtime route handler's `req.url` carries the container's own bind address — so the absolute `Location` it emitted named localhost. Middleware was unaffected, because Next.js normalises same-origin *middleware* redirects to a relative value, which is why only this one route was wrong. Every exit from that route now goes through `sameOriginRedirect`, which emits a relative `Location` (RFC 7231 §7.1.2). It needs no environment variable, no forwarded header and no canonical host, so it cannot drift — and it keeps the visitor on whichever host they signed in on. The helper refuses protocol-relative and absolute targets, because this is an auth path assembling its destination from cookie-borne values and `//evil.example` is an open redirect. The existing tests read the `Location` back through `new URL(...).pathname`, which discards the host — so they passed against a localhost redirect. They now assert the header verbatim.
v1.7.1
2026-08-03Consumer accounts made usable, seller-owned marketing, and the first production hardening of the remarketing vertical
v1.7.0 built the remarketing vertical; 1.7.1 is what it took to put it in front of real people. A consumer can now sign up with Google or a password that actually works, confirm their address once at the account level, and manage the sellers they hear from. Sellers get their own promotions, campaigns and analytics — scoped to their own storefront rather than the platform's. And three authorization defects found on the way to prod are closed.
Consumer accounts
- Sign up with Google on the marketplace and on any storefront, resuming to wherever the person started.
- Account-level email verification (`User.emailVerifiedAt`) — the mailbox is proved once, on the account, and every subscription that follows is consent the moment it is given. A welcome mail carries a single-use confirm link; platform staff can re-send it, which re-mints the token so a leaked link cannot be replayed.
- Unconfirmed accounts are still marketable. They are shown with their state rather than dropped from an audience — the protection an unconfirmed address buys is that the real owner hears about it immediately, not that nobody may write to them.
- Due diligence for a person = confirmed mailbox + complete profile, surfaced as its own column in `admin/customers` with a resend action.
- "Your sellers" as its own section, with a one-click unsubscribe.
Seller-owned promotions, marketing & analytics
- A seller can create and run promotions on their own storefront, with campaigns and per-promotion analytics — the same components as the platform's, scoped to the org.
Cypress account managers *(new in this release)*
- One named Cypress staff member per customer org, assigned from the org's admin profile and reported beside every closed sale.
- The Sales report gains year and month filters — "who owned each deal that closed in July".
Photo ordering *(new in this release)*
- Drag to arrange a vehicle's photos in the editor. The arrangement is respected everywhere the vehicle appears — marketplace card and detail, storefront, seller hub — and photo 1 becomes the cover.
Vehicle version in exports *(new in this release)*
- Trim as its own column in the fleet, seller-hub, post-sale and archived exports, so a spreadsheet can filter and pivot on version.
Authorization
- `/orgs/<id>` had no authorization at all — any signed-in user could walk the id space and read every org's name, location, member count, vehicles sold, revenue and spend. Refusal is now `notFound()`, so the id space cannot be probed either.
- A private person was presented as a company in the post-sale grid, a CSV column headed "Buyer", and two Cedar prompts: a consumer org's `name` is the person's legal name. The org slot and the seller's own export record are now separate facts.
- `GET /api/closing` derived platform staff from `session.role` alone, handing every customer-org admin CM privileges on their own closing. `session.role` describes authority *within* an org, never which org.
- A seller's promotion picker offered the whole platform's inventory; it now offers only vehicles on that seller's storefront, enforced at every point that resolves a promotion's audience or catalog.
Sign-in and mail
- A password set at registration did not work. Accounts were created without a Logto username, and the sign-in experience identifies by username — so the password was correct and unusable.
- Sign-in is moved onto the canonical host before the session cookie is set, fixing a host-only cookie that broke sign-in on the apex domain.
- Campaign estimates counted nobody while the send delivered. The estimate and the send resolved their audiences through different code; they now share one function, so the two numbers cannot drift.
- The unconfirmed-recipient warning counts only active memberships — it was describing people the send never reaches.
Storefront & marketplace
- A promotion hero on a storefront was broken and offered no way back to the full catalog.
- The hero CTA was unclickable — a caption overlay covered it. It affected the marketplace's pill too, for as long as that pill existed.
- Subscribe is an icon with a confirmation dialog that says plainly what is being agreed to, and header sign-in/register actions match the public marketplace.
- Buyer Hub's "Via" column is now "Channel", with the same pill treatment as the Seller Hub.
- Cards no longer render blank when a vehicle has photos but no cover flag — two card queries selected on the flag rather than ordering.
Other
- `next build` had been failing since 2026-07-30 and v1.7.0 reached staging in that state: a component exported from a `page.tsx` is an invalid Page export, which neither the tests nor the typechecker can see.
- An individual account has no role to choose — the picker is gone and the server refuses the field.
- Revoking access now works for a sole admin. Being the only one was treated as a reason to refuse.
v1.7.0
2026-07-31Remarketing OS: Sales Channels, Direct Links, Storefronts & Consumer Accounts
The remarketing vertical. Inventory stops being "listed or not" and gains **sales channels** — the public Marketplace, **Direct Links** (private share links), and a seller's own white-labelled **Storefront** — each an opt-in grant with its own commission scheme, its own attribution, and its own fee at closing. Sellers choose channels per vehicle or in bulk; buyers arriving through any door are told which one they came through; and a storefront can now acquire a **consumer** directly, so an individual can register, subscribe, and make an offer without ever being a dealership.
Sales channels & commission
- `SalesChannel` + `CommissionScheme` models with platform seeds, and a CM settings panel to manage channels and schemes.
- Per-org opt-in grants: `P2P_LINK` and `STOREFRONT` are granted per organization (managed from the org page), with an org-list column showing each org's effective opt-in channels and a confirm-and-republish dialog when a grant is revoked.
- Channel-bound offers: every offer records its originating channel and snapshots the commission scheme + amount, so the fee billed at closing is the one that was disclosed when the offer was made — not the org's current scheme.
- Closing step ownership by channel, plus a CM commission-scheme override that can be applied before the FEE step fires (row-locked against the trigger race).
- Offer and post-sale surfaces show the origination channel, commission and net proceeds (integer-cents arithmetic throughout).
Direct Links (private share links)
- `/p2p/[token]` landing route with channel-stamped engagement, resuming the buyer to the link after signup.
- Create / list / revoke / reactivate / soft-delete endpoints, a Seller Hub link manager with fee disclosure, a default share link provisioned on every launch (and backfilled when an org is granted the channel), and per-link visitor drill-down.
- `LINK_ONLY` visibility: a vehicle can be withheld from the marketplace, alerts, AI picks and promotions while staying live on its link — with a CM alert when a `LINK_ONLY` vehicle has no usable link left.
- Bulk generate / export / revoke links across a selection, with the full URL in the export.
Storefront (white-labelled D2C)
- Public `/s/<slug>` and `/s/<slug>/v/<vehicleId>` — marketplace-shaped chrome (hero, promotions strip, footer) composed from the same SRP and vehicle-detail components as the Marketplace, scoped to one org's catalog, with navigation, breadcrumbs and the related strip all staying inside the storefront.
- A Storefront nav section and route for sellers: editable title, logo and banner upload, theming, publish/unpublish, and an offer to opt the existing fleet in when a storefront is first published.
- Bulk add/remove from storefront in the Seller Hub, alongside bulk marketplace visibility, grouped and explained in the bulk action bar.
- Dual-authorized configuration — a CM can configure a storefront on the org's behalf through the same endpoint the FM uses.
- An explicit subscribe control for visitors, and a My Storefronts page listing the storefronts a customer has subscribed to.
Consumer accounts
- Individual organizations (`isIndividual`) — a consumer gets a real Cypress account without being presented as a dealership.
- Registration asks which kind of account first (business or individual) at one canonical `/register`, and at `/s/<slug>/register`; consumer signup asks only name, email, phone and a password, with the address deferred to closing.
- Acquisition attribution (`acquiredViaOrgId`) is recorded separately from marketing consent (`StorefrontSubscriber`) and the two must stay separate: nothing reads attribution to decide who may market to someone. An unsubscribe is stamped permanently (`lastUnsubscribedAt`) so a later re-subscribe cannot erase the record of it.
- A dedicated `admin/customers` section for individuals — origin storefront, live-consent count, inline profile editing, and the same credential actions (temp password, send reset, revoke access) as a business org — instead of showing a one-person account inside Business Orgs.
- Signed-in customers get a scoped experience: Today, Deals, Post-Sale and their own profile, with billing and org settings absent.
Channel visibility in analytics & the Seller Hub
- Per-vehicle channel → instance → outcome Sankey in Market History, share-link milestone markers, and channel labels on visitor entries.
- Engagement, sales, revenue and traffic rolled up per sales channel (and per share link), with traffic-source double-counting eliminated.
- Seller Hub shows each vehicle's reachable channels as chips and in the CSV export.
- Buyer Hub gains a Via column (and CSV field) naming the channel each offer went through, using the same `channelLabelFor` vocabulary as the seller hub and post-sale.
Operations
- Database backups run 4× daily to R2 with an empty-DB guard that blocks both upload and prune, plus a daily volume audit.
Changed
- P2P is rebranded to Direct Links in all user-facing copy. A one-off data script (`scripts/rebrand-direct-links-data.ts`) renames the two seeded channel rows — it must be run per environment at deploy.
- Storefront "Setup" is now "Settings", with pinned save actions.
- Analytics keys channel identity on channel *type* rather than the channel row id, and never renders a raw enum name as a label.
Fixed
- Bulk-visibility write-skew and `storefrontEnabled` liveness gaps closed; `storefrontEnabled: true` is gated on org liveness and pre-existing bad rows are healed.
- Revoking a `STOREFRONT` grant republishes inventory it would otherwise strand; `revokeLink`'s revert decision is gated on real storefront liveness; remaining-link counts are grant-aware.
- Channel chips no longer claim a channel that can reach nobody, and `defaultLinkToken` is gated on the grant like the active-link count.
- The seller's storefront hero no longer advertises the Cypress marketplace; signed-in visitors are no longer asked to create an account.
- Saved vehicles and `OFFER_SUBMITTED` engagement events carry channel origin, closing a `LINK_ONLY` leak through saved vehicles and public user profiles.
- The bulk action bar no longer clips off screen.
Security
- A CUSTOMER-org admin is not Cypress platform staff. `session.role` is `'ADMIN'` for a one-person consumer org exactly as for a ROOT-org Cypress admin, and 15 admin routes checked only the role while taking the target org id from the URL — a signed-in user of any org could read another organization's documents *with presigned URLs*, and its lender and bank-account records. Fixed at the definition (`isPlatformStaff`, ROOT org type required), with 11 hand-rolled copies of the check collapsed onto it.
- A seller can no longer read a private individual's legal name; individuals are excluded from dealer counts and platform campaign audiences.
- Consumer registration is rate-limited and the duplicate-account race is closed.
- Uploaded bytes are magic-byte verified on the remaining upload routes; the storefront banner stores an object key and presigns on read; storefront theming values are sanitized before reaching the CSS sink.
v1.6.4
2026-07-13Marketing landing mobile nav
Fixed
- The marketing landing nav collapses into a hamburger menu below 940px instead of overflowing the header.
v1.6.3
2026-07-13Hero ad under reduced motion
Fixed
- The marketing hero ad keeps animating under `prefers-reduced-motion` / iOS Low Power Mode (a deliberate override — the ad is the page's content, not decoration).
v1.6.2
2026-07-13Retention axis & dormant clients
Fixed
- The retention month axis is anchored to the current month via SQL `now()`, and stale clients are reported as Dormant rather than Active.
v1.6.1
2026-07-13Client Cohort Retention
Added
- A Retention tab in CM Analytics with cohort retention, a Cedar retention insight, and an animated hero ad on the marketing site.
v1.6.0
2026-07-10Engagement Analytics, Promotion Marketing & Admin Org Parity
An analytics-and-marketing release. A full **Engagement Analytics** suite lands on both the promotion Analytics tab and the vehicle Market History — KPIs, a conversion funnel, engagement/activity heatmaps, a real geographic map, audience breakdowns, visitor and per-vehicle grids, a **Source → Vehicle → Outcome → Sold Sankey**, and a **Sakura promotion insight**. Promotion **marketing campaigns** gain a full audience model (filter / cherry-pick / paste-an-email-list), tracking, and sends. Cypress Managers get real **org parity** — upload documents and fully manage a specific org's pricing rules on behalf of a business org — and the dashboard adopts a **shared header/tab UI convention**.
Engagement Analytics (promotion + vehicle scope)
- Overview KPIs grouped into Email delivery (Sent/Delivered/Opened/Clicked) and On-site engagement (Viewed/Saved/Offered/Visitors), with per-tile deltas and sparklines, and a Hot/Warm/Cold temperature summary.
- Conversion funnel — a single stepped-bar panel (registered vs anonymous split, % of previous stage and % of first stage) with distinct per-stage Cedar colors.
- Engagement histogram + Activity heatmap — daily engagement colored by magnitude (blue→orange→red), a 7×6 day/hour heatmap, and campaign-send event markers.
- Geography — a real d3-geo basemap (world `geoNaturalEarth1` / US `geoAlbersUsa` with native AK/HI insets) with value-scaled heat bubbles; Audience device/auth donuts.
- Visitors grid and By-vehicle grid on a shared TanStack stack (sort / filter / CSV + Excel export), the by-vehicle grid carrying a Hot/Warm/Cold Market badge.
- Engagement flow Sankey — `Source (Promotion vs Organic) → Vehicle → Outcome (Viewed/Saved/Offered) → Sold`, built from real `EngagementEvent` attribution, custom-rendered with `d3-sankey` and hover-highlight.
- Sakura promotion insight — an auto-generated campaign-performance narrative (temperature, reach→engaged→offers, top/laggard vehicles, promotion-vs-organic conversion) shown on the Analytics tab; cron-refreshed every ~23h and generated on demand on first open.
Promotion Marketing & Campaigns
- Promotion campaign audience with three modes: Filter (roles/segments minus exclusions), Cherry-pick (explicit orgs), and a new Email list — paste a raw list of recipient emails (newline/comma-separated) as the audience.
- Campaign tracking + sends (open/click attribution feeding the analytics above) and a provisioned `promotion.campaign` Novu email workflow (hero image, vehicle list with tracked clicks, unsubscribe + open pixel).
Cypress-Admin org parity
- CMs/Admins can upload documents on behalf of a business org (`POST /api/admin/orgs/[id]/documents`, `requireCM`-guarded), and the admin Documents tab now uses the same three placeholder slots (W-9 / Dealer License / Other) as the customer view via a shared `DocumentSlot`, plus Review + AI-recommendation.
- A Pricing Rules tab on the admin org detail lets a CM view + create + edit + delete a specific org's listing pricing rules, with a clear "no rules → every vehicle needs a manual price and every offer goes to manual review" summary.
Shared UI convention
- New shared `PageHeader` + `TabBar` primitives (plain title + actions + icon tabs) adopted across Settings, admin org detail, promotions, Audit, and Analytics.
Changed
- Vehicle Market History tab reordered — the narrative story (status hero, Sakura summary, launch snapshot, buyer network, weekly chart) leads, with the detailed analytics panels below; the geography/audience breakdowns now match the promotion dashboard.
- Account Settings adopts the shared plain header (its bespoke white panel is retired); the duplicate "Settings" nav entry is hidden for platform staff.
Fixed
- The settings tab bar's stacked "double underline" is eliminated by the shared `TabBar` (single underline; active-only accent).
- Client bundles import shared analytics utilities from pure submodules so promotion-analytics/market-history Node builtins never leak into the browser build.
v1.5.0
2026-07-07Market History Intelligence, Sakura Market Summary & Buyer-Facing Seller Anonymization
A seller-analytics and buyer-privacy release. The **Market History** tab is rebuilt into a real engagement-intelligence view — Hot/Warm/Cold status, the buyer network as a baseline, and each seller-side update correlated with its impact on engagement — threaded through the fleet grid and a branded PDF report, and narrated by a new **Sakura Market Summary**. On the buyer side, the Marketplace and Buyer Hub are consolidated onto one vehicle-detail component and **seller identity is anonymized** across all buyer-facing surfaces.
Market History Intelligence (seller-hub)
- Hot / Warm / Cold engagement status is now the headline of the Market History tab (with a week-over-week trend arrow) and a new sortable Market column on the fleet grid — grid and panel share one status/trend formula so they never disagree.
- Buyer network as the baseline: every engagement number reads against the platform's buyer network — total active buyer dealerships, plus how many are *interested* (org-level alert-matched and behaviorally-active on similar vehicles).
- Update → engagement correlation: seller-side lifecycle events (price change, promotion in/out, photos, details, location) are captured as `VehicleNotice` events, shown as milestone markers on a weekly engagement sparkline, and measured for their 7-day before/after impact ("Price cut 10% → +4 views, +1 save").
- Sakura Market Summary — an auto-generated narrative (engagement status, buyer-network reach, most impactful update, suggested next action) shown in an `AgentBlock` beneath the status hero and included in the PDF; single-vehicle views generate fresh, bulk export reuses a <24h-cached summary with a concurrency cap.
- Valuation vs Market reuses the Fleet "Sequoia — Market Valuation" panel (wholesale + range, trade-in, Condition/Mileage/BB UVC), extracted into a shared `OrbisValuationCard`.
- Branded PDF report (individual + bulk zip export): Cypress header, per-page footer with page numbers + confidential disclaimer, a vector-drawn engagement chart, launch snapshot, buyer network, correlation/updates history, valuation, and the Sakura summary.
- Engagement backfill: a one-off, idempotent `pnpm backfill:engagement` reconstructs historical `VehicleEngagementDaily` (views + unique visitors from `BuyerView`, offers from `Offer`) so charts aren't empty for pre-existing inventory.
Admin
- Org member credential actions (Force Provision / Set Temp Password / Send Reset) are now gated on whether the member has a Logto account (a derived `provisioned` boolean, never leaking the raw Logto sub) rather than inferred from status.
Buyer-facing seller anonymization
- The Marketplace (logged-in) and Buyer Hub now render the same unified vehicle-detail component (authenticated mode + a shared action panel and offer-math helper), replacing the divergent detail implementations.
- Seller identity is removed from all buyer-facing surfaces — no seller org name or `/orgs` link on cards, detail pages, offers, ai-picks, or API payloads. Buyers instead see an anonymized, stable seller handle plus non-identifying credibility signals (rank badge, active-listing count, join date).
- The temperature palette for engagement status is a literal heat scale everywhere (Hot = red, Warm = orange, Cold = light blue); the seller-hub Overview consolidates its AI panels into a single Sakura `AgentBlock`.
Fixed
- Market History PDF export no longer 500s in the Turbopack/serverless runtime — pdfkit's default-font AFM load is bypassed by registering a bundled TTF via the constructor (verified with pdfkit's font data removed). Export errors are now surfaced instead of a bare 500.
- Days-on-market falls back to `createdAt` when `listedAt` is null (so listed-but-unstamped inventory shows a real figure), consistently in the panel and grid.
- Bulk-import valuations are labeled per-vehicle (Black Book vs Orbis) instead of inheriting a batch-level flag; `buildValuationMatrix` tolerates all persisted `rawResponse` shapes and captions its data quality.
- Marketplace authenticated detail coerces a Decimal `conditionScore` before formatting (fixes a `toFixed` crash); logged-in view/dwell tracking restored.
v1.4.0
2026-07-03Public Marketplace, Curated Promotions, Marketing site & Sakura Vehicle Insight
A seller-acquisition and public-facing release: an unauthenticated **Public Marketplace** lets prospective buyers browse and drill into real inventory before registering, **CM Curated Promotions** replaces the old Campaign scaffolding with a real promotion builder (cherry-pick or live-filter vehicle sets, AI hero images, scheduling), a new **marketing home page** replaces the bare site root, and vehicle detail pages gain seller credibility, condition reporting, related vehicles, and an AI-written **Sakura Vehicle Insight**.
Public Marketplace (unauthenticated)
- New `/marketplace-public` and `/marketplace-public/[vehicleId]` routes — grid/list/gallery views, a rate-limited public "Ask Sakura" natural-language search, and a teaser vehicle detail page with a register-gate modal for buyer-only actions (Buy Now / Make an Offer).
- Public API responses redact sensitive fields (e.g. floor price / `autoDenyBelow`) and are shared with the authenticated Marketplace via an extracted, single vehicle-list query builder and search-parsing core (no duplicated filter logic).
- Recently-sold vehicles stay visible and clickable for 30 days (`Sold` badge, "Hide sold vehicles" filter, offer/buy CTAs disabled on sold detail pages).
CM Curated Promotions (Milestone 2)
- New `Promotion`/`PromotionVehicle` models replace the unused Campaign scaffolding. Promotions support two vehicle-selection modes: cherry-pick (manual multi-select, with "select all filtered" and a selected-vehicles preview) and dynamic query (a live saved filter, previewed via a controlled Marketplace search-results component).
- Full CM authoring flow: list + create + detail/update/publish/pause/archive/delete APIs, an AI hero-image generation endpoint with graceful fallback plus manual upload, and a redesigned full-page editor (grouped sections, sticky save footer, an aurora Cedar explainer banner, and a real Delete action backed by the app's first `ConfirmDialog`).
- Public-facing surfaces: a promotion landing page, a rotating hero banner on the Marketplace (replacing the old promotions strip, cap raised 10→50), and an all-promotions index page — each distinguishing a hidden/expired promotion ("no longer available") from a genuine 404.
- New shared UI components used across the editor: `ConfirmDialog`, `CedarInsightBanner`, `StickySaveFooter`, `SectionLabel`.
Marketing home page
- The site root is now a real public marketing home page (ported from the approved HTML mockup via a scoped CSS module), with a `Reveal` scroll-fade-in component and a `ContactModal`. Login moved to its own `/login` route. Header includes a direct "Browse the Marketplace" link.
Vehicle detail enhancements
- Seller credibility card: tenure tier (New/Established/Veteran) and lifetime sold count, plus a geocoded city/state map (Nominatim-based geocoding, backfilled and cached on the vehicle record).
- Condition report and related-vehicles strip added to the public vehicle detail page.
- Sakura Vehicle Insight — a cached, AI-generated per-vehicle marketplace insight (fair-value framing grounded in real valuation/condition data, no walk-away suggestions or fabricated facts), pre-computed by a cron and shown in an `AgentBlock` panel beneath the Buy Now / Make an Offer buttons; automatically hidden once a vehicle is no longer `LISTED`/`UNDER_OFFER` so stale promotional copy never lingers past a sale.
Other
- Business organizations now get a sequential org number assigned on creation.
Fixed
- Marketing page button text was invisible against its background (CSS specificity bug: `.page a` outranked the button's own color class).
- The vehicle-detail seller map now uses a real MapLibre/mapcn map on free OpenFreeMap tiles (replacing a frequently-down static map image), with a static-OSM-tile fallback for browsers/environments that can't create a WebGL context.
v1.3.4
2026-07-01Marketplace seller links & automation trigger fix
Changes
- fix(marketplace): seller name in Marketplace grid/list/detail views is now a link to the seller's company profile (rank/score tier, seller/buyer stats) — previously plain text with no way to reach the org profile from the Marketplace.
- fix(automation): CM approval of a CSV-staged floor-price change now fires automation triggers. The approval endpoint was missing the `enqueueAutomationRun` call present in the sibling price-change approval flow, so flows watching `floorPrice` silently never ran after CSV import + CM approval.
v1.3.3
2026-06-25Email normalization
Changes
- fix(auth): the login-sync path now lowercases emails (matching the edit path), so emails are stored consistently lowercase. Mixed-case records self-heal on next login, and a case-only edit (e.g. `Irene@` → `irene@`) is no longer needed. One-time backfill lowercased the existing mixed-case records.
v1.3.2
2026-06-25User-admin parity
Changes
- feat(admin): internal staff user panel now supports editing email and Set Temp Password (generate a one-time password to share directly), matching the Business Users panel — all via existing Logto helpers.
- feat(admin): Business Users panel gains a Send Reset action (emails a Logto reset link).
- refactor(admin): shared `TempPasswordCard` component used by both panels.
v1.3.1
2026-06-24Sales reporting & removed-sale visibility
Changes
- fix(archive): delist route now creates an ArchiveRecord (no more "ghost archives" invisible to the Archive page / rollback). Backfilled 131 existing ghosts.
- fix(analytics): closing completion now stamps `saleCompletedAt` + `finalSalePrice`, so live sales appear in analytics instead of showing $0.
- feat(analytics): new Sales tab — two-moment report (sold-on-platform vs due-diligence-completed), days-to-close, CSV/XLSX export. Demo/test orgs excluded via `Organization.isDemo`.
- feat(post-sale): Removed group surfaces completed sales later archived/reverted, with the captured reason; `revert-delivery` now requires a reason.
v1.3.0
2026-06-19A buyer-side release: the **Marketplace** gets a ground-up redesign with an AI-assisted search, smarter filters, Sakura recommendations, and a real **alerts engine** (saved searches that watch inventory, notify, digest, and auto-save). Cedar's Copilot reaches the **Buyer/Seller hubs and offers** — it can now read offer status and accept/counter/reject behind the confirm-rail. Pipeline and Today metrics are re-grounded on **wholesale GMV** (the figure Cedar already cites) with a separate retail KPI.
Marketplace overhaul (buyer)
- Redesigned discovery layout — a unified top section (AI search → quick filters → a single Sakura picks strip → results grid → sticky footer) replaces the easily-lost bottom strip. The filter sidebar is collapsible and the discovery rail remembers its open/closed state.
- Ask Sakura search — natural-language queries ("2024 SUVs under $30k in FL, low miles") are parsed into structured filters.
- Sakura recommendations — a picks strip driven by your recent buying activity, each card carrying a reason tag and a rich "why Sakura picked this" tooltip. The same pick card now renders inside Cedar's Copilot results.
- Smarter filters — added a location (state) filter; quick-filter chips (Saved + each saved search) reset and re-apply cleanly over the sidebar.
- View modes & card badges — Grid / List / Gallery views, plus at-a-glance card badges (New, Under market, Hot).
- Alerts engine — save a search and it watches inventory for you. Per saved search you choose what a match should do: instant push, daily digest, and/or auto-save matches to your Saved list. Creating an alert backfills against existing inventory (not just future listings). Saved searches are editable and deletable from one rich modal, with a clickable match-count chip to view the vehicles that matched.
- Smarter daily digest — the digest reports the full set still matching each alert (new ones flagged, plus already-seen), with a per-alert choice between new matches only and all still-matching vehicles.
Cedar Copilot — hubs & offers
- Buyer/Seller Hub & Due-Diligence view control — a generic `set-view-filter` surface tool lets Cedar filter, read, and guide you through the Seller Hub, Buyer Hub, and Due Diligence (Phase 2 / A1–A4).
- Read your offers — "list my offers" returns buyer-side offer status.
- Act on offers — Cedar can accept, counter, or reject an offer as a confirm-rail action, routed through a single shared `applyOfferAction` (same logic as the offer API), with role→action mapping and validation.
Metrics — wholesale GMV truth + retail KPI
- Pipeline & Today KPIs — now show wholesale GMV (the truth, matching what Cedar reports) alongside a separate retail value KPI, backed by a shared `rollupFleetValuation` reducer.
- Lifetime Sold GMV — pipeline summary shows lifetime sold count + GMV, using the actual sale price (accepted offer amount).
- Today decision cards (B1) — launch straight into the matching Cedar Copilot flow.
Admin / testing
- Manual triggers as a real testing tool — the buyer-alert match-scan and digest jobs run as dry-runs showing the real matches/content (new vs existing) with no side effects, a Run for real commit path, and a per-session run log.
Fixed
- Silent decline — rejecting an offer with the silent option no longer notifies the buyer; counter/decline notes typed in the seller-hub offer panel are no longer dropped.
- GMV consistency — Cedar's pipeline GMV uses the latest non-stale wholesale figure, at parity with the web metrics.
- Marketplace polish — the Sakura why-tooltip renders via a portal (no longer clipped by the carousel); pick "Save" persists to your Saved list; the view switcher aligns to the sort row; the sidebar toggle shows a filter icon.
- Cedar reliability — faster fail-over on Gemini high-demand (503) instead of slow retries, reduced tokens-per-turn to avoid the today-summary quota (429), and due-diligence reads the populated `ClosingProcess` model.
- Freshly-opened fleet — Cedar no longer reports "no vehicles" while a just-opened page is still filtering; the work-surface header no longer clips the badge/voice controls in the narrow panel.
v1.2.0
2026-06-16Cedar becomes a true co-pilot: a new **Copilot workspace** in the app and a full **Telegram channel** — both driven by the same agent, tools, and confirm-rail, so anything Cedar can do, it can do by text, by voice, or from your phone.
Copilot mode (Cedar Workspace)
- One brain, two inputs — a bottom-docked Cedar Bar (conversation) plus a right-hand Work Surface (live results), additive to the existing panel chat. Text and voice (Gemini Live) share the same conversation, memory thread, and tools.
- Work Surface — renders Cedar's work as it happens: a live step-trace while it's thinking, rich result cards (valuation, pricing, pipeline, vehicle lookup), and inline Confirm / Cancel for any write.
- Screen-aware — Cedar knows the page you're on and the vehicle you have open; "value it", "should I sell this", "set the listing price" resolve to the focused vehicle without repeating yourself.
- Drives the app for you — surface tools navigate, filter the fleet by lifecycle stage, select rows for bulk actions, export the current view, and open panels.
- Scenes — jump to Today, Onboarding, Pre-listing, Pipeline, Offers, Marketplace, Seller/Buyer Hub, or Post-sale and get briefed on arrival.
- VIN Quick Appraisal panel — embedded in the workspace (and openable by Cedar via voice/text): enter a VIN + mileage/condition/location, get a full valuation + sell/hold, and file it into a business org as a draft.
- Feedback loop — thumbs up/down on any answer feeds Cedar's learning loop.
Telegram channel
- Text and voice notes — speak or type; Cedar replies in rich formatted text and, for voice, with a spoken reply (named `cedar_<action>_<date>`).
- Tap-to-confirm — every proposed change arrives with inline Confirm / Cancel buttons (YES/NO text still works for voice).
- Car cards — ask about a vehicle and get its cover photo, status, prices, location, and an "Open in app" deep link.
- VIN from a photo — snap a VIN (sticker, screen, or document) and Cedar reads it and values the vehicle.
- Photo upload — send a photo of a car and Cedar attaches it to that vehicle's record (the one just discussed, or the one you name).
- Quick-action deep links and live status ("Cedar is typing…" / "recording audio…") while it works.
- Account linking — connect your Cypress account from your profile with a one-time code.
Cedar capabilities (available in both channels)
- VIN Quick Appraisal — value any VIN on the fly with no record created: wholesale/retail/trade-in bands (Black Book when configured, heuristic otherwise), a sell/hold read, and a suggested floor + target. One step to file it into a business org as a draft (queues a real valuation).
- Apply Pricing — set floor and target together in a single confirmation.
- Offering rules — configure auto-accept / auto-deny thresholds as a confirmable action.
- Launch to marketplace — list approved vehicles (optionally applying offering rules in the same step), preserving any existing rules.
- Bulk actions — group-select and bulk launch or withdraw offers.
- Daily & weekly pipeline summaries — snapshot, what changed in the window (new / launched / sold), GMV on wholesale, due-diligence and health, with deltas vs the prior period.
Fixed
- Valuation consistency — Cedar, the vehicle panel, and recommendations now all cite the same wholesale figure (the Cedar card previously showed retail, contradicting the panel).
- Voice pricing flow — "suggest prices → yes" now proposes then confirms correctly; a suggestion is no longer mistaken for a confirmable action.
- Org scoping (Telegram) — Cedar now sees the full fleet across all organizations (previously under-counted).
- VIN OCR — reliably reads VINs from screens and documents, not just physical plates.
v1.1.0
2026-06-12Cedar Automation Builder (Ask Cedar co-pilot)
- Ask Cedar flow co-pilot — build and edit automation flows in natural language. The `CopilotPanel` is wired into the Flow Canvas behind an Ask Cedar toggle, and the standalone CedarFlowModal brings the same builder into the Automation Hub.
- Server-side change diff — the co-pilot computes its changeset server-side and renders it in a `FlowChangeDiff` panel so you see exactly which nodes/edges will be added, removed, or modified before applying.
- Validate-and-repair loop — generated graphs (and the co-pilot's changeset) are validated and auto-repaired, including value-level validation against the real node-config schemas, so malformed AI output is corrected instead of crashing the canvas.
- Single source of truth for the node/condition catalog — Cedar is given the real node and condition definitions (incl. Fuel/Powertrain condition), eliminating hallucinated schemas. Detailed per-node reference baked into the system prompt.
- AI flow + node summaries — flows now get an auto-generated summary; node summaries are persisted. The co-pilot opens with the flow's build history for context.
- Polished co-pilot UX — markdown-rendered replies, auto-growing composer, clearer first-prompt, fuchsia agent accent on the Ask Cedar buttons, node-settings open on click, and a Retry affordance when a reply is truncated. UX brought to parity with the main Cedar chat.
- Canvas pan/select toggle — explicit pan/select tool toggle plus Space-to-pan.
Automation Nodes (18 new)
- Triggers (6) — `OFFER_RECEIVED`, `OFFER_ACCEPTED`, `VALUATION_COMPLETE`, `DUE_DILIGENCE_STARTED`, `ENTITY_TIME_ELAPSED` (Time Elapsed), `SCHEDULE`.
- Conditions (6) — `HAS_OPEN_OFFER`, `HAS_VALUATION`, `LISTING_DURATION`, `HAS_PENDING_DD_TASKS`, `OFFER_VALUE`, `DD_STATUS`.
- Actions (5) — `SET_FLOOR_PRICE`, `REQUEST_VALUATION`, `DELIST`, `ASSIGN_TO_FLEET`, `CREATE_DD_TASK`.
- Control (1) — `FETCH_ENTITIES` (Fetch Vehicles) with a Batch-Run-style filter / VIN / preview config UI.
Marketplace Photos
- High-resolution detail gallery — the new `VehiclePhotoGallery` component serves the hi-res `previewKey` for the hero / 360° viewer and the 200px `thumbnailKey` for the strip. Fixes the pixelated zoom caused by stretching the thumbnail into the hero.
- Grouped 360° viewer — photos tagged `360` collapse into a single drag-to-rotate viewer that leads the carousel; stills follow, sorted by `sortOrder`. Animated transitions via framer-motion, with photo `caption`s surfaced.
Fleet Import
- Resilient condition normalization — condition parsing is centralized in `lib/import-condition.ts`. Unrecognized values (e.g. `"Unspecified"`, `"Grade 4.3"`) are now non-blocking: the row is flagged in the review and imported with a sensible default instead of `400`-ing the entire batch. Numeric 1.0–5.0 grades and aliases (`average` → `FAIR`) are canonicalized; numeric grades in the Condition column flow through as a condition score.
- "Cleaned by Cedar" review detail — the dry-run review now shows per-row normalizations: color cleanup (`blk` → `Black`), VIN cleaning, condition-grade cleaning (`cleaned`), and ZIP → state/city resolution (`derived`, ZIP kept as-is). You see exactly what will be stored before importing.
- Unit tests — `import-condition.test.ts` covers the strict/loose normalizers and the batch-safety rule.
Fixed
- Co-pilot crashes on malformed AI nodes — the canvas no longer crashes on malformed AI-generated nodes; apply is guarded and raw code is hidden from replies.
- Gemini structured-output truncation — disabled the Gemini 2.5 thinking budget that was truncating structured co-pilot output.
- Sticky notes flagged as orphaned — flow validation no longer flags sticky-note nodes as orphaned; edge handles are normalized.
- Marketplace hero inflated by thumbnail strip — `minWidth: 0` on the photo column lets the `1fr` track shrink instead of blowing out to the strip's max-content width.
v1.0.0
2026-06-10Automation Engine (full feature)
- React Flow Canvas — drag-and-drop automation builder with a node palette, property panel, and canvas topbar. Supports undo/redo via React Flow's built-in history. Flows are saved as versioned `FlowVersion` records; published versions are immutable.
- Node Palette — all node types are drag-to-canvas. Disabled Phase 2 nodes display an amber `SOON` badge. Sticky Note nodes for canvas annotations.
- Trigger Nodes — `ENTITY_CREATED`, `ENTITY_UPDATED` (with field-level `watchFields` filtering), and `MANUAL`. Entity-context config forms with status and field pickers.
- Condition Nodes — `ATTRIBUTE_MATCH` (multi-condition builder with AND/OR), `HAS_LABEL`, `TIME_IN_STATUS`. Conditions are filter nodes: pass → continue, fail → branch stops.
- Action Nodes — `APPLY_LABEL` (with `AUTO` / `NOTIFY_CM` behavior), `REMOVE_LABEL`, `CHANGE_STATUS` (with floor/listing price guards), `SET_LISTING_PRICE` (% or flat markup above floor), `APPLY_PRICING_RULE` (auto-accept / auto-deny auction thresholds), `SEND_NOTIFICATION` (recipient role, channel, title, message, severity — fully wired to Novu), `CREATE_NOTE` (writes to vehicle activity timeline, repurposed from `CREATE_TASK`).
- Control Nodes — `BRANCH` (if/else with condition builder), `SWITCH` (multi-case with per-case conditions and a default handle).
- Property Panel — context-sensitive config form for every node type. Includes AI-powered node summaries (streamed via `/api/admin/automation/nodes/summarize`) and AI flow summarizer.
- Canvas Topbar — Save, Publish, Dry-run, and Run buttons. Version history dropdown with rollback. Flow metadata (name, scope, status).
- Dry-Run Modal — simulate flow against a single test vehicle. Shows per-node results, condition traces, and before/after field diffs without writing to the database.
- Batch Run Modal — run a published flow against many vehicles at once, with two selection modes: By Filter (status chips, make, year range, search) and By VIN (paste VINs, resolve to vehicles, preview before launch). Launch button renamed to Run.
- Run History & Logs — paginated run list with aggregate stats (vehicles evaluated, actioned, duration, status). Per-run detail drawer shows every step's condition trace (which conditions passed/failed and why) and before/after field diffs.
- Execution Engine — BullMQ-backed `AUTOMATION_RUN` queue. Graph traversal with per-node step logging. Dry-run mode skips all DB writes but logs every step as `DRY_RUN`. Cascade prevention: actions that fire `ENTITY_UPDATED` only do so at cascade depth 0, preventing infinite loops.
- Entity-aware Trigger Matching — `ENTITY_UPDATED` flows subscribe to specific changed fields (`watchFields`). The worker filters flows by changed field intersection, so a flow watching `status` does not fire when only `listingPrice` changes.
- Automation API — full CRUD for flows, publish, versioned save, rollback, duplicate, summarize (AI), run (batch + per-vehicle), and run log endpoints.
- Label Picker — searchable label picker with create-new-label modal for `APPLY_LABEL` and `REMOVE_LABEL` nodes.
- Automation Notification Novu Workflow — `automation-notification` Novu workflow provisioned with in-app and conditional email steps. `SEND_NOTIFICATION` nodes no longer expose internal `workflowId` config.
Pricing Rules
- Dry-Run API — `POST /api/admin/pricing-rules/dry-run` evaluates a rule against the fleet without applying it. Returns per-vehicle outcomes (APPROVE_AND_LIST / REQUIRE_MANUAL_REVIEW / AUTO_REJECT) with condition traces.
- Test Rule Panel — inline dry-run panel in the pricing rule form drawer. Shows a live table of outcomes as the rule is edited.
- AGE Condition Type — price rules can now condition on vehicle age (years since manufacture year) with GT/GTE/LT/LTE operators.
- Rich Condition Builder — rewritten with chip-style inputs, make picker, vehicle-type picker, and inline operator selects.
Fixed
- `SEND_NOTIFICATION` silently dropping notifications — the Novu `automation-notification` workflow was not provisioned; notifications were discarded. Workflow is now provisioned; `workflowId` is hardcoded in the worker and removed from user-facing config.
- `CREATE_TASK` writing nothing — repurposed to `Create Note`, writing a real activity timeline entry via `recordVehicleEvent` instead of `console.log`.
- `MANUAL` trigger storing vehicleId on node — removed VIN picker from the MANUAL trigger config; vehicle selection moved entirely to the Run modal, decoupling flow definition from runtime data.
Changed
- Trigger types renamed — `VEHICLE_IMPORTED` → `ENTITY_CREATED`, `STATUS_CHANGED` → `ENTITY_UPDATED`. Migration script provided for existing stored flows.
- Batch Run modal — renamed from "Batch Run" to "Run" in topbar; gains "By VIN" tab.
v0.9.2
2026-06-06Added
- 3rd-Party Inspection Reports — Manheim Condition Reports (and any future provider) can be imported via URL during fleet import. The pipeline automatically: fetches the inspection data (grade, damages, announcements, tire depth, DTC codes), creates an `Inspection` record, downloads and stores all photos (stills + 360° frames) to MinIO, and triggers Black Book re-valuation with the real condition score.
- Condition-first BB Valuation — when a vehicle is imported with a 3rd-party URL but no explicit condition, BB valuation is deferred until the inspection report sets the real condition score. Eliminates the GOOD/3.0 placeholder valuation that required manual re-run.
- BB Re-derive from Stored Response — when condition changes after BB has already run, the valuation is recalculated directly from the stored raw BB response (all four tiers are saved) instead of making a new API call. No duplicate BB calls on condition update.
- TP Failure Fallback — if the 3rd-party fetch fails after all retries, the BullMQ `failed` handler automatically enqueues BB with the default condition (3.0/GOOD) so the vehicle is never left without a valuation.
- Worker Queue Tooltip — hovering the fleet grid refresh button shows a live dark tooltip with per-queue job counts (running / queued / failed) for all four worker queues.
- Fleet Grid — Worker Status Indicators — vehicles with active workers show a spinning blue badge on the vehicle thumbnail. The valuation cell shows "QUEUED" / "AFTER INSP." spinners. The Last Update column shows "Fetching report…" during TP import. Cedar heuristic badge replaced with a neutral "AI pending · awaiting Cedar" state instead of a false HOLD/SELL.
- Fleet Grid — Silent Auto-Poll — when any vehicle has an active TP or valuation worker, the fleet auto-polls every 8 s in the background without triggering the loading skeleton (no more full-page flash).
- Refresh Button — toolbar refresh icon with live worker-activity indicator dot. Fetches data silently without blanking the fleet.
- 3rd-Party Report in ARVIS Strip — the Manheim grade appears as a compact single-line score row (mini bar + number + label) inside the ARVIS pricing cell alongside Self and AI scores, with a "View report ↗" link.
- Vehicle Side Panel — Full Photo Carousel — panel now fetches all photos on open (not the `take:6` fleet preview). 360° frames are grouped into a single drag-to-rotate viewer (first in the carousel). Still photos follow, sorted by `sortOrder`. Dark scrollable thumbnail strip with active-thumb auto-scroll.
- 360° Viewer — Smooth Drag — replaced threshold-based frame advance with accumulated-delta logic (300 px = one full rotation regardless of frame count). Sub-pixel remainder carries over between moves for consistent feel on touch and mouse.
Fixed
- Import — `thirdPartyReportUrl` dropped on submission — the `handleImport` function built the vehicle payload without including the Manheim URL (dry-run builder had it; actual submit didn't). All future imports now correctly carry the URL through to the worker.
- Condition enum not updated after TP fetch — `third-party-report-fetch` worker set `conditionScore` from Manheim but never updated the `condition` enum field. BB used the stale enum for tier lookup, producing "Good" even when the score was ≥ 4.0 (Excellent). Both fields are now synced together.
- `conditionAtVal` mismatch — valuation workers stored `conditionAtVal` from `vehicle.condition` (the enum) rather than deriving it from `conditionScore`. Now uses `scoreToTier(conditionScore)` → `tierToConditionEnum()` as source of truth so the displayed "CONDITION USED" always matches what BB actually used.
- Orbis cache stale on condition threshold change — cache key used the raw `condition` enum rather than the resolved BB tier, so changing `scoreToTier` thresholds would serve stale cached results. Key now includes the BB tier derived from `conditionScore`, ensuring cache busts automatically when mapping changes.
- Condition scale mismatch — `scoreToTier` thresholds (4.5 for xclean) didn't match the UI condition bands (4.0 = Excellent). Aligned to UI: 4.0–5.0 → xclean, 3.0–3.9 → clean, 2.0–2.9 → avg, 0–1.9 → rough. Single source of truth in `@cypress-mvp/shared` consumed by Orbis, worker, and web-v2.
- Side panel photo carousel overflow — thumbnail strip didn't scroll; 51 thumbnails overflowed the panel width. Fixed with `overflowX: auto` and dark scrollable container.
- 360° photos shown as individual frames — carousel listed every 360° frame as a separate photo. Frames are now grouped into one draggable viewer item; the thumbnail strip shows a single "360°" entry.
Changed
- Photos independent of BB — photo import is enqueued directly after TP fetch completes, no longer chained to BB valuation. BB and photos run concurrently on separate queues.
- Orbis `buildCacheKey` — accepts optional `conditionScore` and uses the resolved BB tier as the cache dimension instead of the raw condition enum.
v0.9.1
2026-06-05Added
- Fleet Import — Soft-Field Updates — seven metadata fields now update on re-import without CM staging: `exteriorColor`, `interiorColor`, `purchasePrice`, `purchaseDate`, `category`, `equipment`, `previousRepairCost`. Column mapping supports all seven fields in UPDATE mode. Diff detection and worker apply path extended across dry-run, route, and batch UPDATE processor.
- Fleet Import — Missing Vehicle Archiving — after dry-run, a non-invasive banner prompts the manager if org vehicles are absent from the file ("N vehicles not in this file — add to list for archiving?"). Accepting injects them as pre-checked `ARCHIVE` rows directly into the unified review table. Per-row and group-level checkboxes let the manager deselect exceptions. Selected vehicles are sent as `archiveMissingVins` in the import payload and processed as staged `UpdateRequest(kind=ARCHIVE)` records.
- Fleet Import — Grouped Review Table — the review table now groups rows by intent (`ERROR → NEW → UPDATE → ARCHIVE → SKIP`) with colored group headers showing counts. ARCHIVE group has a group-level select/deselect checkbox and per-row checkboxes. Unchecked archive rows are excluded from the import payload.
- Fleet Import — Confirm Modal Breakdown — the confirmation modal replaced the misleading Total/Importing/Skipped stats with a Processing (new + updates + archives) / Skipped (no-change + errors + unchecked archives) split that matches the summary bar.
- Fleet Import — Done Step with Pending Summary — the completion screen shows a per-category breakdown (new vehicles / updates / archive requests), an amber banner when archives are pending CM approval, and a direct "Update Requests (N)" button linking to the ARCHIVE-filtered update-requests queue.
- Vehicle Color Normalization — `normalizeVehicleColor` helper (in `@cypress-mvp/shared`) maps free-text CSV color values to canonical `COLOR_OPTIONS` names with three quality levels: `exact`, `fuzzy` (alias match), `unknown`. Applied at import time in both route and worker; also runs on vehicle form load so legacy lowercase DB values display correctly in the `ColorPicker`.
- Expanded COLOR_OPTIONS — added 12 generic color names (White, Black, Silver, Gray, Charcoal, Brown, Tan, Gold, Orange, Yellow, Purple, Teal) alongside the existing OEM-style names.
- Color match quality badge — the import review table shows a `~ normalized` (yellow) or `! unknown color` (red) badge on `exteriorColor` diff rows when the CSV value was fuzzy-matched or unrecognized.
- Update Requests — Recently Updated filter — amber chip (matching the Fleet grid) filters requests created in the last 24 hours. Deep-link support: `/admin/update-requests?kind=ARCHIVE` pre-selects the Archive kind filter.
- Listing Availability — default to today — the "Available From" date field in both the onboarding sheet and seller-hub pre-fills to today's date instead of showing blank.
Fixed
- Import — soft-field dry-run blind spot — `dryRunVehicles` payload was missing all 7 soft fields, causing `exteriorColor` and others to never detect changes (rows showed SKIP instead of UPDATE).
- Import — inline UPDATE path — the small-fleet inline path only applied mileage/condition; soft-field changes were silently dropped. Extended to include all 7 fields.
- Import — missing vehicle archive not appearing in update requests — synthetic ARCHIVE rows were missing `disposition: 'ARCHIVE'` in the `ReviewRow`, so `handleImport` never sent the field and `route.ts` couldn't classify them as ARCHIVE.
- Import — `fileVinSet` false positives — missing-vehicle detection used only *valid* CSV rows to build the exclusion set; invalid rows (wrong format) could cause their corresponding org vehicles to be falsely offered for archiving. Now uses all CSV VINs regardless of validation status.
- Import — `purchaseDate` invalid date crash — unvalidated date strings (e.g., "January 2024") passed to `new Date()` in the worker could produce `Invalid Date`, failing the Prisma update. Worker now guards with `Date.parse()` before constructing.
- Notices — Updates tab empty — `VehicleNoticesFeed` resolved audience as `CM` for CM users, returning zero notices. Added `audienceType` prop; seller-hub, fleet side panel, admin quick panel, and post-sale panel now explicitly pass `audienceType="SELLER"`.
- Notices — `DateNoticeCell` showed raw enum — `UPDATE_REQUEST_APPROVED` and other unmapped kinds displayed as raw strings ("UPDATE_REQUES..."). Fixed fallback to use `noticeSummary` when no label mapping exists, matching the fleet page pattern.
- `NOTICE_KIND_LABEL` incomplete — 15 VehicleNoticeKind values had no label entry; extended the map to cover all 19 kinds.
- Seller Hub — `availableAt` stale after Today/+10 click — `onRefresh` re-fetched the vehicle list but `selectedVehicle` held the old object; the panel re-rendered with the stale `availableAt: null`. `fetchListings` now syncs `selectedVehicle` from the freshly fetched list.
- ColorPicker — legacy lowercase colors show blank — `vehicle-onboarding-sheet` now normalizes the incoming `color` value via `normalizeVehicleColor` on form initialization, so historical lowercase DB values ("white", "silver", etc.) display correctly.
- `archiveMissingVins` deduplication — input VINs are now deduplicated via `Set` before the `findMany` lookup to prevent duplicate synthetic rows.
- N+1 query — per-VIN `findUnique` loop for `archiveMissingVins` replaced with a single `findMany` batch.
Changed
- Import — done step title changed from "Import complete" to "Import queued" (accurate — processing is async via BullMQ).
- `LARGE_FLEET_THRESHOLD` — capped `missingVehicles` `findMany` at 1,000 rows to prevent unbounded scans on large orgs.
- Synthetic ARCHIVE rows in the import review now use `found.vin` (exact DB case) instead of the uppercased VIN to avoid case-sensitive `findUnique` misses in the worker.
v0.9.0
2026-06-04Added
- Unified Fleet Import — single-mode import pipeline replacing the previous add/update toggle. Rows are auto-classified by intent (new vehicle vs. update vs. duplicate) with intent badges and diff rows shown in the review table. Data cleaning runs automatically with `CLEANED` indicators for VIN normalization and condition score extraction. Auto-mapping uses two-pass sample-value detection (VIN tokens, ZIP codes, place names) to eliminate mis-mapping and clears low-confidence guesses. Location ZIP fields are re-mapped to Location Name when a place name is detected.
- Registered Import Locations — new `OrgLocation` model with full CRUD API (`/api/settings/locations`). Sellers register named locations (with ZIP) once; import auto-matches by name or ZIP. Unmatched locations surface in a banner during import review for inline registration. Match source (name match / data match) is shown in the mapping table. `SequoiaMatchBadge` (pulse dot + sparkle icon + rich tooltip) distinguishes matched from unresolved rows.
- Locations Settings Tab — new Locations tab in org settings (`/settings`) with add/edit drawer, ZIP auto-resolve, and soft-delete confirmation.
- Import Location Side-Panel — `ImportLocationPanel` replaces inline location registration forms during import, replacing the Batch Update nav item which is now superseded by unified Import.
- Submit-for-Review Validation Gate — fleet rows missing mileage or ZIP are blocked from submission. Invalid rows are highlighted amber with a warning badge; `SubmitValidationBanner` summarizes outstanding issues. Batch submit partitions valid/invalid rows and confirms valid ones while leaving invalid ones highlighted. Auto-clears resolved rows on the next vehicle refresh.
- Batch Price Fixed-Delta Mode — `set-floor-price` and `set-listing-price` batch actions now support a fixed dollar-amount delta in addition to multiplier mode. `FloorPriceModal` and `SalePriceModal` gain a `%` / `$` toggle to switch between modes.
- Sapele AI in Post-Sale Hub — `SapeleInsightCard` upgraded to an LLM-backed card with "Ask Sapele" and "Re-analyze" actions. Health badge always shown; Sapele insight appears below it when available. `generateClosingAnalysis` now correctly routes to the Sakura agent.
- Unified Fleet Grid — shared `GroupByDropdown`, group-by action, and consolidated column set applied across the fleet board.
- CM Cross-Org Settings Access — Cypress Managers can now view and edit locations, lenders, and bank accounts for any org. New `Lenders`, `Bank Accounts`, and `Locations` tabs added to the admin org detail page (`/admin/orgs/[id]`). All three settings APIs accept `?orgId` for cross-org writes; `LendersTab` and `LocationsTab` gain `orgId` prop support matching the existing `BankAccountsTab` pattern.
- Retail Price Privacy — retail valuation figures removed from all seller-facing and buyer-facing surfaces (fleet grid, vehicle side panel, onboarding sheet, seller hub, activity feed, marketplace detail, Cedar AI context). Value remains in the database and continues to power internal calculations (multiplier mode, valuation filters, pipeline delta %). Admin/CM views retain full visibility.
Changed
- Batch Update nav item removed — superseded by the unified Import flow.
- Submit-for-Review requirements simplified — mileage + ZIP are the only required fields (city, state, and color requirements removed).
Fixed
- Floor/Sale price modals — negative result guard on `FloorPriceModal`; toggle disabled during loading state; null guard for zero-value base prices; negative delta display format corrected.
- Fleet bulk selection — highlights now union rather than replace on multi-select; badge hides correctly during pending edits; dead select fields removed.
- Import location mapping — `Location Name` mapped column satisfies the `Location Zip` requirement; `handleRegisterLocation` guarded against double-click race condition.
- Location create API — `null` field values from the client now accepted by the POST schema (`.nullish()` instead of `.optional()` on nullable fields); previously caused every add-location form submission to fail with a 400.
v0.8.6
2026-05-29Added
- Provisional (limited-access) onboarding stage — a new `PROVISIONAL` org status between ONBOARDING and ACTIVE. A CM grants access once the minimal checklist is met (business profile, terms, ≥1 member — all auto-completed from registration data); the org can then sign in and work — browse, import and valuate vehicles — but cannot list vehicles to the marketplace (sellers) or make offers / buy-now (buyers) until fully onboarded. A persistent dashboard banner lists the outstanding steps and links to settings.
- Two-step CM activation — "Grant Access" moves an org ONBOARDING → PROVISIONAL (limited); "Mark Fully Onboarded" moves PROVISIONAL → ACTIVE once the full checklist (W-9, dealer license, billing) is complete, unlocking everything.
- Onboarding notifications — `onboarding.activated` (fully onboarded, features unlocked) and `onboarding.reminder` (sent to a stuck PROVISIONAL org at day 3 then weekly, escalating to CMs after 2 weeks or once all docs are submitted). Worker reminder sweep added.
- `GET /api/org/onboarding-status` — business-scoped onboarding progress for the banner.
Changed
- Checklist split into MINIMAL / FULL phases and `FLOOR_MODEL` removed. Minimal items auto-complete from registration data.
- The approval email now frames access as "limited" and points the user to finish onboarding.
v0.8.5
2026-05-29Fixed
- Users could sign into a non-approved organization (security) — the dashboard gate had an exception that let CM-invited ONBOARDING orgs through. Now only ACTIVE orgs (and the ROOT org) can access; every onboarding/suspended/rejected org goes to `/no-access` until activated.
- CM-created orgs no longer email "your account is approved" at invite — the invited admin is created `PENDING` and gets a "registration received / pending review" notification (no password-setup email yet). Password-setup + "approved" email now fire only when the CM activates the org.
- Activation unified — both the self-registration approval (`/approve`) and the generic `activate` action now: set the org ACTIVE, activate the admin user(s), send the Logto password-setup email, send the `onboarding.approved` notification, and create the Lago billing customer (if not already linked). This also fixes the missing "your org is approved" email after approval.
- Email links pointed to the wrong domain — notification CTAs fell back to a hardcoded `staging.app.getcypress.xyz` when `NEXT_PUBLIC_APP_BASE_URL` was unset; they now use `NEXT_PUBLIC_APP_URL` (correct per environment).
- Billing customer created at approval — Lago customer is created when the CM approves/activates an org (after reviewing tax id), not left as a manual step.
- Contradictory member status pills — the "Access Active" pill no longer shows next to "Pending"; it only appears when access is actually revoked.
- First-login UX — Logto sign-in experience reconfigured to be email-based (password + one-time verification code) instead of forcing a username at first login; "Forgot password?" now works (email is a verifiable identifier). New `scripts/configure-logto-signin.py`. Approval/invite emails now tell the user to "Sign in with a verification code" for the first sign-in, then set a password (was misleadingly "click a link to set up your password").
v0.8.4
2026-05-29Added
- Self-service organization registration — public `/register` wizard (org info with ZIP→city/state auto-resolve, seller/buyer profile cards, contact) plus a redesigned split-screen landing page. Submitting creates the Prisma org (ONBOARDING) + RegistrationApplication and a passwordless Logto user; the contact receives a "set your password" email only after a Cypress Manager approves.
- Business settings — Organization tab — org admins can edit their onboarding info (name, website, tax ID, contact, address) via `GET/PATCH /api/org/profile`. Uses the same sectioned `bg-card` panel + Edit/Save pattern as root settings.
- Business settings — Team tab — org admins can invite teammates, change roles, and remove members from `/settings`, rendered with the same row-list pattern as the platform Users list. Invites now send the Logto set-password email + `onboarding.invited` Novu notification.
- `onboarding.invited` Novu workflow — new email template for invited members; all onboarding workflows re-provisioned.
Changed
- CM-created orgs start in ONBOARDING (was ACTIVE immediately) so the checklist is completed before activation; the existing `activate` action flips them live.
- Activity logging is now dual-written at the business-org and ROOT-org level for org register/approve/reject/create, member invite/role-change/remove, admin user management, and self-service profile/password changes (new `logActivityAtOrgAndRoot` helper).
- Password reset unified — self-service, CM-on-org-user, and admin-on-platform-user paths all send the Logto reset email and write an audit entry.
- Removed the unused `tier` (DEMO/PRO) concept — dropped from the registration form, admin UI, session/types, and the database (`OrgTier` enum + `Organization.tier` / `RegistrationApplication.tier` columns; migration `20260528114350_remove_org_tier`).
Fixed
- Notifications were silently undelivered — the worker's `NOVU_SECRET_KEY` was stale (401) and the Novu instance had zero provisioned workflows; key synced and all workflows re-provisioned. Backend triggers now use the Prisma user id as the Novu subscriber (matching the inbox), with the worker resolving email by id-or-logtoId.
- Novu workflows migrated to v2 — `provision-novu-workflows.ts` now creates native v2 workflows (`origin: novu-cloud`, dashboard-editable) with an explicit `workflowId` instead of v1 templates whose trigger id was derived from the workflow name. This fixes silent `workflow_not_found` (422) mismatches where the worker's trigger id didn't match the name-slug (e.g. `onboarding-invited`, the closing-incident/cancellation and billing-credit-restored events that were never provisioned). Templates use v2 `{{payload.*}}` variables. Catalog pruned to the 74 live workflows (removed the dead `offer-fm-approved-awaiting-cm`). Re-provisioned across local, staging, and production.
- Notification preferences 500 (`raw is not iterable`) — Novu returns `{ data: [] }`; route now unwraps it and treats a missing subscriber (404) as empty preferences.
- Unauthenticated/sign-out redirects now land on the new landing page (`/`) instead of the old `/sign-in`; middleware allows `/`, `/register`, `/api/public`, `/no-access` for guests.
- Logto user creation 502 — `createLogtoUser` tolerates Logto returning a non-JSON `201 Created` by falling back to an email lookup.
Security
- No login while pending — users of ONBOARDING orgs awaiting approval are routed to `/no-access`; fixed the redirect loop where `/no-access` bounced membership-holders back to the dashboard, and stopped `syncLogtoUser` from auto-activating users whose org isn't ACTIVE yet.
- Admin scope enforced — `requireAdmin`/`requireCM` now require ROOT-org membership (a CUSTOMER-org ADMIN no longer passes), plus a server-side `/admin/*` layout guard. Business-org admins are no longer misrouted to root settings.
v0.8.3
2026-05-27Changed
- Historical sales source switched to v1 spreadsheet, single-sheet — converter now reads ONLY `Sheet1` from `docs/import/Fleet Sales Tracking v1.xlsx`. The "Fleet Sales Tracking" tab (in both v1 and v2) is an older snapshot whose only unique-to-it VINs are 22 stale KL4AMBSL Buick Encore rows mislabeled as `seller=Hertz` (buyer Fox Rent A Car). Dropping that sheet removes the 22 phantom Hertz sales with zero data loss. Hertz now reconciles to the product owner's truth: 21 VINs total (13 SOLD + 8 CANCELLED). New overall totals: 146 records (138 SOLD / 8 CANCELLED) — was 168 (152 / 16). Monthly: Jan 2 · Feb 39 · Mar 42 · Apr 54 · May 9.
v0.8.2
2026-05-27Changed
- Historical sales classification rule — converter now classifies rows by the presence of a Cypress Proceeds value rather than absence of Cancelled markers. New rule: `Invoiced=Cancelled` → CANCELLED (archived); else `Cypress Proceeds > 0` → SOLD (imported as Invoiced=PAID / InvoicePaid=PAID so historicals land in a fully-closed state); otherwise the row is skipped (insufficient signal). This correctly archives the 16 Hertz May cancellations and drops 3 records that had no proceeds value. New totals: 152 SOLD / 16 CANCELLED (was 155 / 16). Monthly breakdown: Jan 2 · Feb 39 · Mar 42 · Apr 76 · May 9 (incl. cancellations).
v0.8.1
2026-05-27Added
- Fleet — Responsible column — new sortable column on the Fleet board between IMPORTED and ACTION, showing who owns the next step on each row using the same pill style as the Post-Sale Hub: "Seller" (purple) for steps owned by the Fleet Manager, "CM Review" (purple outline) when a Cypress Manager is actively gating the vehicle (status REVIEW), and "Cypress Manager" (gray) for steady-state CM ownership (LISTED, ON_HOLD, post-sale flow).
- Fleet — VIEW AS tooltip — info icon + tooltip on the CM-only VIEW AS toggle explaining that it switches between Fleet Manager and Cypress Manager perspectives for preview purposes only (real permissions are unchanged).
Changed
- Fleet — Action column cleanup — removed the inline FM/CM role badge from each row's Action cell, now redundant with the new Responsible column.
- Historical sales dataset refreshed (v2) — replaced source file with `Fleet Sales Tracking v2.xlsx`; regenerated `docs/import/historical-sales.json`. Net effect: 8 records flipped SOLD → CANCELLED (mostly May Versas/K4s), 2 seller reassignments (NU → Hertz), 1 closing-step advance (`billOfSaleSent`). New totals: 155 SOLD / 16 CANCELLED (was 163 / 8). May 2026 GMV drops from $226k to $15k, all other months unchanged. v1 source archived at `docs/import/Fleet Sales Tracking v1.xlsx`.
v0.8.0
2026-05-26Added
- Admin Analytics dashboard — new `/admin/analytics` (Cypress-Manager only) with five tabs: Monthly KPIs, Top Customers (real org names, layer-cake stacked chart), Cash-Basis Export, Action Funnel, and Path to $100M (actual vs base & stretch forecasts, annualised run-rate).
- AI portfolio insights (Cedar) — `/api/admin/analytics/insight` route produces board-ready 3-bullet narratives for Monthly, Top Customers, and Path to $100M tabs. Cedar is told the most recent month is partial (migration in progress) and anchors trend conclusions to the last fully-loaded month.
- Reusable sortable DataTable — click column headers to sort asc/desc, automatic totals row (sum or weighted), per-table "Download CSV" button with properly quoted export including totals row. Applied to every analytics table.
- KPI tile tooltips — info icons + hover popovers explaining the definition of every KPI on the dashboard (Total units, GMV, Cypress proceeds, Take rate, Avg sale, Total GMV to date, Annualised run-rate, % of $100M target).
- Historical sales import pipeline — `scripts/convert-historical-xlsx.js` (Excel → canonical JSON) and `scripts/import-historical-sales.ts` (idempotent loader creating orgs, users, vehicles, valuations, offers, closings, commission invoices, and audit logs). Supports `--dry-run` and `--purge` flags. Companion snapshot at `docs/import/historical-sales.json`.
- Forecast constants — `packages/shared/src/forecasts.ts` exports `FORECAST_BASE_CASE` and `FORECAST_STRETCH` monthly GMV/annualised series, consumed by the Path-to-$100M chart.
- Documentation — `docs/plan/prd-historical-import-cohort-analysis.md` (PRD), `docs/plan/historical-import-staging-runbook.md` (staging/prod runbook), `docs/plan/notifications-v2-prd.md` (notifications V2 plan).
Fixed
- Seller Hub & Buyer Hub pagination — Seller Hub "Won" deals was capped at 100 results and Buyer Hub at 50; APIs (`/api/seller-hub/vehicles`, `/api/seller-hub/summary`, `/api/buyer-hub/offers`) now accept paging params and the Seller Hub page surfaces the full historical dataset.
- Monthly KPI SQL — `42803` GROUP BY error — `getMonthlyKpis` rewritten to use CTEs (`veh_agg`, `inv_agg`, `buyer_agg`) joined on month, eliminating mixed-aggregate / non-grouped column issues.
- Top Customers showing anonymised hashes — `getTopSellers` and `getCohortLayerCake` now `LEFT JOIN Organization` and surface real org names; cohort legend uses real names with the long tail bucketed as `OTHER`.
- Pricing strategy in Closing process — sale price now used as the listing price reference; "Won" deals correctly surface in both Seller Hub and Buyer Hub.
Changed
- Analytics chart typography — shared compact tokens (`AXIS_PROPS`, `LEGEND_STYLE`, `TOOLTIP_*`) applied to every Recharts chart: 10px ticks, 10px legend with 9px icons, 11px tooltips, tighter axis widths and zero-padding margins for denser presentation.
- Analytics page typography — h1 `text-xl` → `text-2xl` (matches Admin Orgs), KPI tile value `text-lg` → `text-[15px]` with tighter padding, tab labels `text-sm` → `text-[13px]`, table body `text-sm` → `text-[13px]`, chart/table titles unified as `text-[11px] uppercase tracking-wider text-muted-foreground`.
v0.7.9
2026-05-26Added
- Notification inbox V3 tabs — Inbox bell redesigned with 5 process-aligned tabs: Org, Fleet, Seller, Buyer, Post-Sale; replaces flat Pipeline/Fleet/Tasks/System tabs; each tab filters by V3 category tags with legacy tag fallback so existing notifications still route correctly until the provision script is re-run
- V3 category tag labels — `org-backoffice`, `fleet-onboarding`, `seller-hub`, `buyer-hub`, `post-sale` tag labels added to bell renderer; legacy `onboarding → Org`, `closing → Post-Sale`, `billing → Org`, `marketplace → Buyer` remapped
- `NOTIFICATION_EVENTS` catalog expanded — added `BUYER_ALERT_NEW_MATCH`, `BUYER_ALERT_DAILY_DIGEST`, `CLOSING_FEE_INVOICED_EMAIL`; all 3 processors that previously used magic strings now reference the catalog constants (`buyer-alert-match`, `buyer-alert-digest`, `invoice-pdf`)
- DD timeline alerts repeatable — `dd-timeline-alerts` queue was fully implemented but never triggered; daily 07:00 UTC repeatable sweep registered in worker startup; sweeps all `IN_PROGRESS` DD records for overdue seller tasks (3d), buyer tasks (5d), and total DD age (10d)
Fixed
- Notification test panel — IN-APP only display — 14 workflow entries were showing only `IN-APP` badge; all Novu workflows already have both `in_app` + `email` steps; corrected the hardcoded `steps` arrays to show `['IN-APP', 'EMAIL']` for all affected entries (buyer alerts, overdue tasks, DD tasks, valuations, all 10 closing workflows)
- Notification Log — workflow names showing "—" — Novu v3 API changed: `GET /v1/notifications` no longer populates `template` (always `null`), only `_templateId` (MongoDB ObjectId). Fixed log route to fetch workflow templates in parallel, build a lookup map by `_id`, and resolve names via `workflowMap.get(n._templateId)`
- test-digest route — fails when no buyer alerts exist — added fallback: if alert-matching loop yields zero vehicles, the route queries the 4 most recently updated `LISTED`/`UNDER_OFFER` vehicles and uses them as sample data; only fails (422) if no listed vehicles exist at all
- listing-refresh queue dead — `orgId` made optional on `ListingRefreshJobData`; processor now runs a global sweep across all orgs when `orgId` is omitted; daily 06:00 UTC repeatable registered
- Valuation failure notification — `valuation` worker now passes `subscriberId` and uses `NOTIFICATION_EVENTS.SYSTEM_ERROR` constant (previously missing `subscriberId`, causing Novu subscriber lookup to fail silently)
Changed
- Provision script V3 tags — all 69 Novu workflows updated with the 5 V3 category tags (`org-backoffice`, `fleet-onboarding`, `seller-hub`, `buyer-hub`, `post-sale`) replacing the legacy flat tag set; ready to re-run against staging/production
v0.7.7
2026-05-21Added
- Terms of Service page — public `/terms` route with full 17-section ToS (Platform Overview, Asset Planning 2.1–2.4, Seller Terms 5.1–5.6, Buyer Terms 6.1–6.4, Remarketing, Governing Law / Oklahoma, Electronic Acceptance, contact at `customerservice@getcypress.ai`)
- Privacy Policy page — public `/privacy` route with 13-section Privacy Policy
- Legal route group — `(legal)` route group with standalone layout (Cypress header + footer, no dashboard nav); no auth required
- Cypress logo assets — `public/logo.svg` (dark text, for light backgrounds) and `public/logo-light.svg` (white text, for dark backgrounds); primary URL `https://app.getcypress.xyz/logo.svg` for Logto branding
- 8 new closing Novu workflows — `closing.bos-ready-buyer`, `closing.bos-review-cm`, `closing.bos-signed-cm`, `closing.bos-declined-cm`, `closing.bos-rejected-seller`, `closing.gate-pass-review-cm`, `closing.title-tracking-check`, `closing.lender-payoff-notification`; provisioned on local, staging, and production (69 total workflows each)
- Novu unavailable graceful state — Notification Log tab shows a neutral info box instead of a red error when local Novu is not running; API route returns `{ unavailable: true, status: 503 }` on network failure
Changed
- Vehicle edit — address field order — ZIP / Postal Code moved to top of Location & Fleet section; entering a ZIP auto-fills State & City; Street Address and Location Name follow below
- Nav sidebar — "My Fleet" → "Seller" — group renamed to Seller; Seller Hub moved into the Seller group (Fleet → Pipeline → Import Fleet → Seller Hub); Deals group now contains only Marketplace + Buyer Hub
- Middleware — public paths — `/terms` and `/privacy` added to `PUBLIC_PATHS` so Logto auth bypass is not triggered
Fixed
- CSV import auto-mapping — Model → Year — replaced single-pass mixed matching with a two-pass algorithm: all fields are checked for exact match first, then partial match; prevents `model_year.includes('model')` from stealing the "Model" CSV column
- CSV import auto-mapping — Model Series → Trim — two-pass fix also resolves `model series` matching "Model" via substring before reaching the exact `'model series'` pattern under Trim
- CSV import duplicate field mapping — `autoMapColumns` now deduplicates: if two CSV columns resolve to the same Cypress field, the higher-confidence match wins and the other is reset to "Not imported"; the manual dropdown also marks already-used fields as disabled with a "Used" badge
v0.7.6
2026-05-19Added
- Org Bank Accounts — new `OrgBankAccount` model + migration; full CRUD API (`/api/settings/bank-accounts`); `BankAccountsTab` settings component with add/edit drawer, masked routing/account display, archive/restore
- Bank Account Picker in BOS step — seller picks their own bank account before uploading the Bill of Sale when choosing "Pay Seller directly"; account persisted as `selectedBankAccountId` on `ClosingStep`
- Buyer Payment Banner (Seller) — when BOS recipient is "Seller", buyer sees a green banner in the Payment step with full bank account details (routing, account number, type, contact)
- Lender Picker in BOS step — seller selects a configured lender before uploading BOS when choosing "Pay Lender (lien)"; persisted as `selectedLenderId` on `ClosingStep`; buyer sees lender banner in Payment step with full payoff details
- Admin Orgs table — Tier column — color-coded tier badge (Demo/Starter/Pro/Enterprise) in the business orgs list
- Admin Orgs table — Onboarding column — inline checklist progress bar (`3/7`) or green "Done" pill driven by `OrgChecklistItem` completion data; API updated to return `checklistCompleted` + `checklistTotal`
- Closing GET route — includes `selectedLender` and `selectedBankAccount` in step data for buyer-facing display
Fixed
- `BosUploadSlot` disabled guard now blocks upload when `paymentRecipient === 'SELLER'` and no bank account is selected (same pattern as lender guard)
- Admin orgs API: `city`, `state`, `onboardingCompletedAt`, and checklist counts added to the GET response
v0.7.5
2026-05-17Added
- Stripe Checkout Session ID stored on every payment reconciliation — enables lazy receipt fetch for all paid invoices
- Lazy Receipt Fetch — `POST /api/billing/invoices/[id]/fetch-receipt` retrieves Stripe receipt URL on demand for ACH payments that were still processing at reconciliation time
- Stripe Payment Method Type — `card` / `us_bank_account` stored on `CommissionInvoice`, shown as "Card" or "Bank Transfer (ACH)" in invoice details panel
- Payment Column in billing table — shows Wire / Deposit / Check / Card / ACH / — for all invoice rows
- Manual Payment Details Section in invoice panel — Method, Amount, Recorded date, and optional Note visible for off-platform payments
- Backfill — session IDs, receipt URLs, and payment method types populated for all 22 existing Stripe sessions
Fixed
- Stripe receipt shown for all invoices — previously only visible on one manually backfilled invoice; now auto-fetched for any invoice with a stored session ID
- Stripe Receipt button hid after manual payment — `record-payment` route now clears all Stripe fields (`stripeCheckoutSessionId`, `stripePaymentMethodType`, `stripeReceiptUrl`) when recording an off-platform payment
- Lago auto-succeeded on invoice creation — Lago's local instance marks one-off invoices as `succeeded` with no payment provider; `closing.ts` now immediately resets `payment_status` to `pending` after `createOneOffInvoice()`
- Lago not updated on manual payment — `record-payment` route now calls `updateLagoInvoicePaymentStatus('succeeded')` when a full manual payment is recorded
Changed
- Billing table: "Type" column renamed, "Payment" column added between Type and Issued
- Invoice panel: Details section expanded with "Payment" row for Stripe payments; new "Manual Payment" section for off-platform payments
v0.7.2
2026-04-18Added
- Vehicle 360° Tabbed View: Reorganized vehicle detail page into three tabs — Onboarding, Listing, and Post-Sale — each showing only the relevant panels for that lifecycle phase
- Status-Based Tab Activation: Tabs auto-enable based on vehicle status (e.g., Post-Sale unlocks after SOLD/DELIVERED), with the current lifecycle phase selected by default
- Focus Indicator: Pulsing cobalt dot on the tab matching the vehicle's current lifecycle phase, visible when viewing a different tab
Changed
- Due Diligence Panel: Moved from Listing tab to Post-Sale tab where it contextually belongs
- Campaign Assignment Banner: Removed from Onboarding tab (campaign info lives in the Listing tab)
v0.7.1
2026-04-17Added
- Vehicle 360° Dashboard Overhaul: Three-column layout with lifecycle stepper, read-only photo gallery, Orbis valuation widget, and action panel
- Wizard Consolidation: Merged 4-step vehicle edit wizard into 3 steps (Identity & Acquisition → Photos & Condition → Valuation & Summary)
- Orbis Auto-Fetch in Wizard: Valuation step auto-fetches full Orbis data on mount — always renders OrbisValuationWidget with wholesale/retail/tradeIn ranges, enrichment, depreciation, and TCO
- ARVIS Cascade Pre-Filters: New inspection page filters by Organization → Fleet → Vehicle (no longer hardcoded to showcase org)
- Compact Lifecycle Stepper: Vertical state progression component on vehicle dashboard
- Wholesale-Prominent Pricing: Vertical pricing layout on 360° dashboard with Orbis confidence badge
- Embedded Reprocess Buttons: Orbis and ARVIS reprocess actions directly in the vehicle action panel
- Fleet Import Inline Editing: Edit error/warning rows directly in the import preview table
- Fleet Import Row Detail Panel: Slide-over side panel for complex row edits during import
- Fleet Import Re-Validate: Re-check edited rows via dry-run API before committing import
- Fleet Import CSV Export: Download failed rows as CSV with all original columns + error reason
- Fleet Import Auto-Valuation: Imported vehicles automatically enqueue Orbis heuristic valuations (BullMQ, ORBIS source tag)
- Fleet Import UX: Improved button labels ("Import N Valid, Skip M Errors"), duplicate VIN error with row references, valuation queue count on completion
Fixed
- Cedar AI Thinking-Model Truncation: Gemma 4 and Qwen3 emit `<think>` blocks that consumed the token budget, leaving truncated insights ("All 1"). Now strips thinking blocks and uses 1024 token budget.
Changed
- LM Studio Model: Updated from previous model to `google/gemma-4-e4b` across all config files
v0.7.0
2026-04-16Added
- Lago Billing Integration: Full commission billing system powered by Lago — schema, client, event emission, webhooks
- Commission Billing Pages: FM billing dashboard at `/dashboard/settings/billing` with overview cards and commission invoices table
- Admin Billing Dashboard: CM admin view at `/dashboard/admin/billing` with org-wide commission summary
- Pay Now / Pay Later: FM chooses between instant online payment or batch conciliation for commission invoices
- INVOICED Status: New `CommissionInvoiceStatus.INVOICED` separates invoice creation (Lago) from payment collection
- Commission Pricing Model: Online payment vs conciliated — with configurable payment window (default 24h)
- Lago Webhook Handler: `POST /api/webhooks/lago` processes invoice events from Lago
- Billing Worker: BullMQ processor for commission billing dispatch on vehicle sale
- Commission Expiry Worker: Background processor for expired payment window enforcement
- Cypress Managed Batch Actions: CM/ROOT can batch-manage organizations and fleets with bypass approval gates
- Lago Infrastructure: Deployed Lago v1.45.1 on Railway (API, Worker, Clock, Frontend, Postgres with pg_partman)
Fixed
- CM Org Mismatch: Fixed routes where Cypress Managers couldn't approve offers, cancel campaigns, see vehicles, or confirm delivery for child org vehicles
- Campaign Pricing Priority: Wizard now uses `targetPrice → askingPrice → floorPrice` chain
- Single-Approval Offer Logic: Fixed offer approval for CM org resolution
- BullMQ Job ID Sanitization: Removed colons from job IDs to prevent Redis key issues
Technical
- `packages/shared/src/lago.ts` — Lago GraphQL client with typed mutations/queries
- `packages/database/prisma/schema.prisma` — Commission models, Lago entities, billing fields
- `apps/web/src/app/api/webhooks/lago/route.ts` — Lago webhook processor
- `apps/worker/src/processors/billing.ts` — Commission billing dispatch
- `apps/worker/src/processors/commission-expiry.ts` — Payment window enforcement
- `scripts/backfill-lago-customers.sh` — Lago customer backfill for existing orgs
v0.6.5
2026-04-13Added
- Novu Notification System: Full integration with self-hosted Novu for in-app and email notifications
- In-App Inbox: Bell icon notification center using `@novu/nextjs` Inbox component with real-time WebSocket delivery
- Email Notifications: Branded HTML email templates with CTA buttons via Resend integration
- Notification Test Panel: Settings page UI to trigger test notifications for any workflow
- Reminders Sweep Worker: BullMQ repeatable job (every 15 min) scans for expiring offers, stale listings, and pending approvals
- 40 Novu Workflows: Provisioned via script — each with in-app + email steps covering vehicle lifecycle, offers, approvals, campaigns, inspections, and system events
- Novu Subscriber Sync: Automatic subscriber creation/update when users are created or profiles change
- Notification Event Catalog: Type-safe mapping of business events to Novu workflow IDs in `packages/shared`
Fixed
- Novu SDK Compatibility: Replaced `@novu/api` SDK with raw `fetch()` calls to fix response validation errors between SDK v3.14.4 and self-hosted API v3.14.0
- Workflow ID Normalization: Both dots and underscores in event catalog IDs are normalized to hyphens for Novu compatibility
Technical
- `packages/shared/src/novu.ts` — raw fetch client with `triggerNotification()` and `triggerNotificationBulk()`
- `packages/shared/src/novu-subscribers.ts` — subscriber PATCH/DELETE via REST API
- `packages/shared/src/notification-events.ts` — event catalog with 40 workflow definitions
- `scripts/provision-novu-workflows.ts` — workflow provisioning with `--clean` flag
- `apps/worker/src/processors/notifications.ts` — dual-path: Novu when configured, Prisma fallback
- `apps/worker/src/processors/reminders.ts` — sweep worker for time-based notifications
v0.6.1
2026-04-13Added
- Excel Import: Fleet import now supports `.xls` and `.xlsx` files in addition to CSV, powered by SheetJS
- Sale Price Column Mapping: `SALE PRICE`, `Selling Price`, `Asking Price`, and `List Price` columns are automatically mapped to Floor Price during import
- Gemini Model Fallback: All AI agents (Cedar, MAVIS, Orbis) now use a fallback model chain — primary `gemini-2.5-flash` (2 retries) → fallback `gemini-2.0-flash` (2 retries) — to handle 503/rate-limit errors gracefully
- `GEMINI_FALLBACK_MODEL` env var: Configurable fallback model ID (defaults to `gemini-2.0-flash`)
- Fleet Import PRD: Added product requirements document at `docs/project/prd-fleet-import-improvements.md`
Fixed
- Partial Import Tolerance: Invalid or incomplete VINs no longer block the entire fleet import batch — valid vehicles are imported and invalid rows are reported individually with specific error messages
- Cedar Error Message: Removed LM Studio reference from Cedar's error response
Changed
- VIN validation relaxed at the Zod schema layer (min length 1 instead of 17) to allow partial VINs through to the detailed validation step
v0.6.0
2026-04-12Added
- Cypress Market: Public vehicle marketplace app (`apps/market`) with search, filters, vehicle detail pages, and MAVIS AI buyer advisor
- Logto Auth (Market): Authentication integration with sign-in/register/sign-out for marketplace buyers
- Railway Staging: Market app deployed to `staging.market.getcypress.xyz` with auto-deploy from `develop`
- MAVIS Agent: Buyer-facing AI agent for marketplace — vehicle search, price guidance, negotiation advice, and market comparables
v0.5.1
2026-04-10Fixed
- Added missing `cypressManaged` column migration for Organization table
- Auto-migration on Railway deploy documented
v0.5.0
2026-04-08Added
- Campaign lifecycle management (create, list, price, approve, publish campaigns)
- Vehicle state machine with lifecycle status transitions
- Activity Center for alarms, alerts, notifications, and tasks
- Orbis market intelligence engine with scraping pipeline
- ARVIS vehicle inspection system
- Multi-tenant org hierarchy (ROOT → SHOWCASE → Business orgs)
- Regional configuration (US + MX markets)
- Distribution channel management