Composite-offer proposal via atomic-multi-create primitives on sales-scheduling-surface and credit-reservation-lock
Why
The Sales Availability planner now supports a single-lesson close path end-to-end: dropdown of Delivery-owned lesson types per Organization, eligibility-by-description against Coaching, reservation through Revenue, hold creation through Delivery, audit via sales.offer.constructed. Operators are already asking how to construct offers that span more than one lesson back-to-back on the same coach. The canonical shape is "a 30-minute Trial followed by a 20-minute Group on the same coach over a contiguous 50-minute window," but the generalization is N lessons of arbitrary types stacked in a single operator decision.
The naive implementation in Sales is to loop the existing single-close N times, releasing prior holds on a mid-loop failure. That works for low N but partial-state windows between holds invite races (the lock-aware projection in Coaching subtracts reservation 1's window before reservation 2 lands, which mostly protects us, but mostly is not always). The atomicity boundary should live where the holds and reservations are created, not in Sales' orchestration loop.
The cleaner factoring is to treat composite as "do N of the existing single-lesson operations atomically" rather than as a new domain concept. Two additive endpoints, one on each of Delivery's and Revenue's contracts, give Sales an all-or-none primitive without touching the underlying state machines. Refunds, cancels, lock-state events, and the funding sub-state machine all continue to operate per-hold and per-reservation. Sales tracks the composite correlation in a Sales-owned table.
This memo proposes the contract amendments and asks Delivery and Revenue for sign-off. Coaching is on the recipient list for awareness; the eligibility-by-description endpoint already accepts a window and a cert set and does not need a contract change.
What
Contract amendments (additive minor version bumps)
Delivery: sales-scheduling-surface v1.2.0 to v1.3.0. Add POST /sales-scheduling-surface/v1/holds/atomic-multi-create accepting an array of single-hold-create request bodies. All requests in one call share a single coach_id, share a single originating_offer_id (Sales-supplied), and are evaluated against the same projection read. Outcome is all-or-none. On success, the response carries an array of created hold ids and lock states, one per input. On first failure, any holds created in the same call are rolled back before the response returns. Per-hold state machines are unchanged; this endpoint is a transaction wrapper.
Revenue: credit-reservation-lock v1.3.0 to v1.4.0. Add POST /credit-reservation-lock/v1/reservations/atomic-multi-create accepting an array of single-reservation-create request bodies. All requests in one call share a single person_id and a single originating_offer_id (Sales-supplied). Outcome is all-or-none. On success, the response carries an array of created reservation ids and lock states, one per input. On first failure, any reservations created in the same call are rolled back. Per-reservation state machine and funding sub-state machine are unchanged. customer.handoff continues to fire once per Person on the first lock confirmation, which means a composite offer that pushes a Person past first-lock generates exactly one customer.handoff, indistinguishable from a single-lesson close at the Lead conversion grain.
Coaching: coach-availability v1.x.x, no amendment. Eligibility-by-description already accepts an arbitrary window and a required_certifications set. For a composite read, Sales sends the union of certs from the selected lesson types and the full composite window. The single returned is_eligible answers "is this coach free across the whole composite window with the union of certs," which is the right question. If we later want per-lesson eligibility decomposition (rare, only matters when individual lessons in the composite have divergent eligibility outcomes), that becomes a separate Coaching ADR.
Sales-internal artifacts
- New service path
submitCompositeCloseOfferparallel to today'ssubmitCloseLesson. Calls eligibility-by-description once with the composed inputs, then calls the two new atomic-multi-create endpoints, then writes the audit row. Single-lessonsubmitCloseLessonstays untouched. - New
sales.composite_offercorrelation table holding(composite_offer_id, leadId, coachId, lockIds[], reservationIds[], windowStart, windowEnd, audit_event_id). Ties the operator decision to the N downstream ids so a composite cancel can find every hold and reservation to release without forensics. sales.offer.constructedaudit event extended (not replaced). Themetadata.lessonsarray carries one entry per lesson with{lesson_type_id, hold_id, reservation_id, window}. Single-lesson offers populate a one-element array. Downstream queries on the metric "offers per converted Lead" continue to work without change.- New
submitCompositeCancelLessonreleasing every hold and reservation in acomposite_offer_id. Single-cancel against one hold of a composite stays legal but should warn the operator that other lessons in the composite remain. - Availability planner gains multi-select lesson types with an ordered "Add another lesson" affordance, computed total window display, and cert-union eligibility composition. Single-lesson stays one click; composite is opt-in. This UX layer waits on the orchestration change above so the close button does not lie about what it can ship.
Crediting and Lead lifecycle
Sales is intentionally not adding a per-lock productivity metric. A composite offer of three back-to-back 20-minute lessons is treated identically to one 60-minute lesson at the Lead conversion grain: one Lead, one customer.handoff, one originating-Lead close. The Sales-internal offers_per_converted_lead metric (already supportable from the existing audit table) counts operator decisions, not locks, and stays accurate whether each decision shipped one lesson or many. The Lead state machine and customer.handoff semantics are explicitly out of scope for this proposal.
Asks
Delivery: ack the sales-scheduling-surface v1.3.0 amendment shape (atomic-multi-create for lesson holds) on this thread. Once the shape is agreed, the version bump and validation note land in one PR per the standard contract-change discipline. Sales would also appreciate a Delivery read on whether the originating_offer_id field belongs on the request envelope, in each per-hold body, or in both.
Revenue: ack the credit-reservation-lock v1.4.0 amendment shape (atomic-multi-create for reservations) on this thread. Once the shape is agreed, the version bump and validation note land in one PR. Same originating_offer_id placement question applies; the Revenue and Delivery answers should converge so Sales' request shape is symmetric across both contracts.
Coaching: FYI only. No contract change requested. If the cert-union approach to composite eligibility has any sharp edge that suggests a future ADR (per-lesson eligibility decomposition, divergent cert outcomes within a composite), please flag now so it can be scoped before Sales builds against the assumption.
Sales' two conditional commitments below are gated on both contract version bumps shipping. Once they land, Sales picks up the orchestration extension and the planner UX in that order.
References
- Single-lesson close path (the pre-composite baseline):
2026-05-16-sales-workbench-close-lesson-ux-plan - Recent Delivery read endpoint Sales is consuming for the lesson-type dropdown:
2026-05-18-sales-delivery-lesson-types-read-api - Sales-scheduling-surface contract being amended:
coordination/contracts/sales-scheduling-surface/README.md - Credit-reservation-lock contract being amended:
coordination/contracts/credit-reservation-lock/README.md - Coach-availability contract (no change requested):
coordination/contracts/coach-availability/README.md - Sales scope and conversion bar:
coordination/domains/sales.md - Contract-change discipline:
coordination/_project-instructions/_OPERATOR.md§Contracts