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) and durationShape (fixed, set, range, nightcount, queue, undefined). Carries resource requirements (refs into Trantor), party capacity, and policy fields like cancelDeadlineSec and modifyRevalidates.
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 range Availability — it projects the bookable windows for display the way a grid pre-renders its slots. A RangeWindowSpec owns 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's maxConcurrent, 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 a range Offer with multi-day stay windows, the BookingCalendar renders 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-surface runs a BookingSurfaceResolver that 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-runtime booking_calendar component 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 BookingConfirmationMailer sends 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 SlotInstance rows 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}/use binds caller-supplied values, and the SchemeMaterializer substitutes them into the shape and builds the real TimeScheme / TimeRule / TimeSchemeEdge rows — 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 optional durationSec; Seldon resolves the strictly-contiguous slots on one scheme + lane, requires alignment to the grid step and the Offer's durationShape, 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 BookingGroup parents 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_offer step handler that books an Offer from inside a workflow (and a seldon.cancel_booking handler for saga compensation). An Offer of kind staged_flow carries an opaque orchestrationActionId pointing at its Daneel Action and rejects a direct POST /bookings (422) — it can only be booked through its DAG.
Lifecycle
Statuses move pending → confirmed → checked_in → live → completed, with cancelled, no_show, expired, and waitlisted branches. cancelDeadlineSec and modifyRevalidates policies gate cancel and modify operations. A lifecycle auto-advancer subscribes to the outbox so booking.starting and booking.ending events 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), a Meerkat that 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 to confirmed.
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's ZoneResolver, 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 EventSeries groups EventSessions (each a bookable occurrence); a Registration is a person's persistent roster spot with capacity and waitlist handling, and Attendance records 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.

Seldon endpoints in the Foundation API reference OpenAPI 3.1 schema for Seldon with request/response shapes, parameters, and a try-it client.
MethodPathPurpose
POST / GET/seldon/v1/offersCreate or list bookable Offers.
GET / PUT / DELETE/seldon/v1/offers/{id}Fetch, replace, or soft-delete an Offer.
GET/seldon/v1/availabilityQuery Availability projections for an Offer over a time window.
GET/seldon/v1/availabilities/{id}/windowsEnumerate bookable windows for a range Availability (date or from/to, duration, party). Valkey-cached.
GET/seldon/v1/availabilities/{id}/booking-surfaceCalendar surface — schedule shape + openings grouped by local day — backing the booking_calendar widget.
POST/seldon/v1/bookingsCreate 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}/cancelCancel a Booking. Releases Trantor holds, fires booking.cancelled.
POST / GET/seldon/v1/booking-groupsCreate a BookingGroup (atomic all-or-nothing over its members) or list groups.
POST/seldon/v1/booking-groups/{id}/confirmConfirm every member, all-or-nothing.
POST/seldon/v1/booking-groups/{id}/cancelCancel the group; lenient over already-terminal members.
POST / GET / DELETE/seldon/v1/time-schemesManage the grid-backing time scheme templates and instances.
POST/seldon/v1/templates/{id}/useMaterialize a parameterized scheme template (Salon / Restaurant / Fitness / …) into a working TimeScheme.
GET/seldon/v1/techs/{id}/routeA technician's ordered bookings for a day, with appointment and travel-buffered hold windows.
POST / GET/seldon/v1/event-seriesCreate or list event series (leagues, tournaments, classes).
POST / GET/seldon/v1/event-series/{id}/registrationsRegister 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.