Primitive spec
Seldon
A universal scheduling primitive that handles appointments, stays, events, queues, multi-stage flows, and open-capacity classes through one model. Offers, availability projections, bookings, and lifecycle emission — not just "slots on a grid".
What it owns
Seldon owns the question "when can this happen, who is doing it, and what state is it in?". It models the bookable concept, projects when it's available, records the customer's claim on it, and drives that claim through a status lifecycle — emitting events at every lifecycle anchor so other primitives can react.
Seldon does not own physical resources (that is Trantor), people (Terminus), money (Payments / Mallow), notifications (Speaker), or workflow orchestration (Daneel). It coordinates them by reference.
Concepts
- Offer
- The thing a customer buys, books, or claims. Polymorphic across
kind(appointment, stay, event, queue, staged_flow, open_capacity) anddurationShape(fixed, set, range, nightcount, queue, undefined). Carries resourcerequirements(refs into Trantor), party capacity, and policy fields likecancelDeadlineSecandmodifyRevalidates. - Availability
- A projection of when an Offer can be booked. One Offer can have several Availability projections — a hotel room on a daily-occupancy grid and in a no-show queue. Five kinds:
grid,range,queue,open_capacity,external. - Range window enumeration
- The read path for a
rangeAvailability — it projects the bookable windows for display the way a grid pre-renders its slots. ARangeWindowSpecowns the duration / granularity / allowed-hours rules (shared with the booking path so the two can't drift); the enumerator steps the allowed hours, fetches the day's busy intervals once, tests each candidate in memory against the source'smaxConcurrent, and checks Offer requirements per window through Trantor. Results cache in Valkey (per tenant·availability·day·duration·party, 30-second TTL) behind a per-(availability, day) generation counter that one booking write busts for every variant. Spans over 14 days are rejected. This is the path behind variable-length stays: onboarding's adaptive provisioner materializes arangeOffer with multi-day stay windows, theBookingCalendarrenders them, and the customer picks the length. - Booking surface (calendar widget)
- The read model behind the customer-facing booking calendar.
GET /seldon/v1/availabilities/{id}/booking-surfaceruns aBookingSurfaceResolverthat returns the schedule's shape — timezone, operating window, open days, cascade edges — plus the decorated openings grouped by local day, with closed days filled in. The render-runtimebooking_calendarcomponent fetches it and draws the business's real calendar (its own hours, closed days, cascade hints, times in the business timezone, day / month navigation) rather than a hardcoded flat slot list. - Self-serve confirmation
- When a customer books from a published site and leaves a contact email, a best-effort
BookingConfirmationMailersends a "you're booked" email through the platform's shared Speaker / SES sender — so confirmations go out even before a tenant has wired its own email provider. It is strictly best-effort: a dispatch failure never rolls back the booking. - Time scheme
- The grid backing model. Templated and instance-parameterised, with verticals (lanes, bays, rooms) and horizontals (courts, stations) as arbitrary dimensions. Generates
SlotInstancerows that grid availability projects. - Scheme templates
- A parameterized blueprint for a whole schedule, stored as a Radiant asset with a declared
variables:block (interval, open / close time, days, …).POST /seldon/v1/templates/{id}/usebinds caller-supplied values, and theSchemeMaterializersubstitutes them into the shape and builds the realTimeScheme/TimeRule/TimeSchemeEdgerows — including cascade edges (a dependent slot derived from a primary one by a fixed gap). The platform ships a set of ready templates — Salon, Restaurant, Fitness, Services — so a new tenant materializes a working chair or table grid in one call instead of hand-building it. - Booking
- A customer's claim on an Offer at a specific Availability position. Anchors are deliberately polymorphic: a grid booking's anchor is a slot id; a stay's is a start/end pair; a queue booking's is a position. Carries Trantor resource holds, Terminus identity refs, and a pinned snapshot of the Radiant policy version at booking time. A June 10 hardening pass closed the remaining double-booking races (and pinned slot anchors to the tenant), so two concurrent claims on the last slot can't both win.
- Variable-duration grid bookings
- A grid booking can span N consecutive
SlotInstances, so a set- or range-duration Offer (a 30 / 60 / 90-minute service) books on the same appointment grid without leaving it. The anchor takes an optionaldurationSec; Seldon resolves the strictly-contiguous slots on one scheme + lane, requires alignment to the grid step and the Offer'sdurationShape, locks all N in id order (deadlock-safe), and restores the whole span on cancel, no-show, or modify. Absent a duration it's a single slot, exactly as before. - Booking groups
- A
BookingGroupparents N sibling Bookings with group semantics: single confirm, single cancel, a single-invoice anchor, and — the load-bearing part — atomic, all-or-nothing creation. The group and every member persist inside one transaction (each member on its own savepoint via the normal booking path); a single member failing for any reason (slot full, no resource, tier gate) rolls the whole group back — no group row, no member bookings, no leaked Trantor holds. Each member carries a distinct derived idempotency key so a resubmit stays idempotent without collapsing the members onto one booking. Confirm is all-or-nothing; cancel is lenient, skipping members that are already terminal. - Staged-flow orchestration
- A multi-stage journey — book a consult, wait, book the procedure, branch on the outcome — is orchestrated by a Daneel DAG, not a Seldon entity. Seldon stays single-booking; Daneel's wait and branch nodes own the gaps and conditional stages. The bridge is a
seldon.book_offerstep handler that books an Offer from inside a workflow (and aseldon.cancel_bookinghandler for saga compensation). An Offer of kindstaged_flowcarries an opaqueorchestrationActionIdpointing at its Daneel Action and rejects a directPOST /bookings(422) — it can only be booked through its DAG. - Lifecycle
- Statuses move
pending → confirmed → checked_in → live → completed, withcancelled,no_show,expired, andwaitlistedbranches.cancelDeadlineSecandmodifyRevalidatespolicies gate cancel and modify operations. A lifecycle auto-advancer subscribes to the outbox sobooking.startingandbooking.endingevents promote status without operator intervention. - Emission pipeline
- Bookings fire scheduled events at each lifecycle anchor (
checkInOpenAt,noShowAt,startedAt, …). A four-worker pipeline backed by a Valkey ZSET (Loader, Watcher, Meerkat, Runner) delivers them in deterministic order. The pipeline was rebuilt for sub-100ms precision: a just-in-time Lambda emit consumer on a lean PHP 8.5 runtime (skipping the Symfony kernel, cold start 5.5s→1.67s), aMeerkatthat pre-warms on the upcoming schedule, and a deliver-at-target path that leads each fire by the learned one-way latency so events land on time rather than late. Daneel hook triggers wire side effects to those emissions. - Waitlist
- Modelled as a Booking with
status=waitlisted, not a separate table. When capacity opens, the Promotion service transitions the highest-priority waitlisted booking toconfirmed. - Field-service (travel-aware)
- For mobile work, an Offer can carry a
travelBufferSec. Seldon then widens only the Trantor hold window to[startsAt − travelBufferSec, endsAt], so a technician whose prior job bleeds into the inbound drive is rejected as a conflict — while the customer-facing appointment window stays un-widened. A service-area gate runs each booking's location through Elliot'sZoneResolver, and a read-only tech-route projection lists a technician's ordered day. Phase A ships a static per-Offer buffer; turning real drive-times into the buffer is the next phase. - Event series & roster
- Leagues, tournaments, classes, and cohorts as an additive layer over Offers — no change to the core booking path. An
EventSeriesgroupsEventSessions (each a bookable occurrence); aRegistrationis a person's persistent roster spot with capacity and waitlist handling, andAttendancerecords per-session presence. Sessions reference their Bookings by opaque id, so the series rides on top of the same booking lifecycle rather than forking it.
API surface
All endpoints are versioned under /seldon/v1/, read tenantId from the bearer token, and return RFC 7807 problem details on error. Booking write paths use Idempotency-Key to make retries safe.
| Method | Path | Purpose |
|---|---|---|
| POST / GET | /seldon/v1/offers | Create or list bookable Offers. |
| GET / PUT / DELETE | /seldon/v1/offers/{id} | Fetch, replace, or soft-delete an Offer. |
| GET | /seldon/v1/availability | Query Availability projections for an Offer over a time window. |
| GET | /seldon/v1/availabilities/{id}/windows | Enumerate bookable windows for a range Availability (date or from/to, duration, party). Valkey-cached. |
| GET | /seldon/v1/availabilities/{id}/booking-surface | Calendar surface — schedule shape + openings grouped by local day — backing the booking_calendar widget. |
| POST | /seldon/v1/bookings | Create a Booking. Holds Trantor resources, checks Terminus entitlement, pins the Radiant policy version. |
| GET | /seldon/v1/bookings/{id} | Fetch a Booking with its full lifecycle state. |
| PATCH | /seldon/v1/bookings/{id} | Modify a Booking. Reschedules swap Trantor holds and resync emissions. Gated by cancelDeadlineSec + modifyRevalidates. |
| POST | /seldon/v1/bookings/{id}/cancel | Cancel a Booking. Releases Trantor holds, fires booking.cancelled. |
| POST / GET | /seldon/v1/booking-groups | Create a BookingGroup (atomic all-or-nothing over its members) or list groups. |
| POST | /seldon/v1/booking-groups/{id}/confirm | Confirm every member, all-or-nothing. |
| POST | /seldon/v1/booking-groups/{id}/cancel | Cancel the group; lenient over already-terminal members. |
| POST / GET / DELETE | /seldon/v1/time-schemes | Manage the grid-backing time scheme templates and instances. |
| POST | /seldon/v1/templates/{id}/use | Materialize a parameterized scheme template (Salon / Restaurant / Fitness / …) into a working TimeScheme. |
| GET | /seldon/v1/techs/{id}/route | A technician's ordered bookings for a day, with appointment and travel-buffered hold windows. |
| POST / GET | /seldon/v1/event-series | Create or list event series (leagues, tournaments, classes). |
| POST / GET | /seldon/v1/event-series/{id}/registrations | Register a person onto the roster (capacity + waitlist), or list it. |
Example: a salon appointment Offer
An Offer with a fixed 60-minute duration that requires one styling chair and optionally a specific stylist:
POST /seldon/v1/offers
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: a3f7b1c2-…
{
"name": "60-minute haircut",
"kind": "appointment",
"durationShape": { "mode": "fixed", "fixedSec": 3600 },
"partyCapacity": { "min": 1, "max": 1, "default": 1 },
"requirements": [
{ "resourceTypeId": "rt_chair", "count": 1 },
{ "resourceTypeId": "rt_stylist", "count": 1, "optional": true }
],
"radiantAssetId": "asset_01J8K2…",
"cancelDeadlineSec": 86400,
"modifyRevalidates": true,
"isActive": true
}
A Booking against a grid Availability projection:
POST /seldon/v1/bookings
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: 9c2e4d1a-…
{
"offerId": "offer_01J9TQ…",
"availabilityId": "avlb_01J9TR…",
"anchor": {
"slotInstanceId": "si_01JAZB…",
"dimensionValues": { "station": "A" }
},
"party": {
"count": 1,
"members": [
{ "terminusPersonId": "per_01JAB1…", "role": "customer" }
]
},
"requirements": [
{ "resourceTypeId": "rt_stylist", "count": 1 }
]
}
How it fits with the rest
flowchart LR
Client[Client] --> S(Seldon Booking)
R[Radiant] -. policy YAML .-> S
T[Trantor] -. hold resources .-> S
Te[Terminus] -. entitlement .-> S
El[Elliot] -. service area + place .-> S
S --> OB[(Audit outbox)]
OB --> D[Daneel]
OB --> Sp[Speaker]
OB --> Pv[Palver]
Sp -. consent gate .-> Te
Seldon is the busiest hub on the platform. Offers carry pointers into Radiant for their pricing and cancellation YAML. Bookings ask Trantor to hold physical resources atomically and release them on cancel or no-show. Bookings reference Terminus for the booker's identity and pass through Terminus entitlement checks for tier-gated access rules. The lifecycle emission pipeline feeds Daneel, which in turn calls Speaker for outbound confirmations and reminders — with Speaker enforcing the Terminus consent gate before any dispatch. Mallow records the financial side of completed Bookings. For field-service offers, Seldon also reads Elliot's service-area zones and places — keeping appointments inside the coverage map and reserving the technician's travel time.
None of those references are foreign keys at the database level. The cross-primitive contract is opaque id in, validated reference confirmed, structured event out.