openapi: 3.1.0
info:
  title: Slotster AI
  version: 0.28.0
  description: >-
    Real-time capacity-aware slot restriction and batching microservice.
    Given a set of slot requests, live driver state, active routes, and vendor
    catchments, returns for each slot whether it is fulfillable and which
    driver would anchor the fulfilment.

    Version 0.2 (Slice 2+3) added route-insertion detour scoring via OSRM.
    Every decision carries detourMinutes and evaluationMode
    ("osrm" or "haversine-fallback") so the caller can distinguish real
    routing from the degraded fallback that runs when OSRM is unreachable.

    Version 0.3 (WORK-2) introduces active-anchor state signalling.
    `POST /slots/events` accepts activate/release events keyed by driver;
    `/ready` reports Redis reachability alongside OSRM; every
    `SlotDecision` now carries `activeAnchorMatch: boolean` — true when
    the picked anchor has an unexpired store entry whose window
    overlaps the incoming slot.

    Version 0.4 (WORK-4 + WORK-5) — customer-zone-aware routing.
    Every `SlotRequest` gains required `customerPostcodeZone` (London
    district, e.g. `SW11`) + `hubId`; every `Driver` gains required
    `hubId`. The optimiser strict-filters by Hub then matches the
    customer's zone via a bundled 52-district centroid map. WORK-5 adds
    the prod-exposure boundary: `X-API-Key` auth on `/check`,
    `/baseline/check`, `/slots/events` (401); per-caller token-bucket
    rate limit (429 + `Retry-After`); race-safe `activate` orderId guard
    (409 `activate_conflict`); per-request OSRM call budget (default
    500). Reason enum grew to 12 codes with `no_route_covers_customer_zone`
    covering both missing-route and budget-exhausted cases —
    consumers should size requests, not build behaviour on server load.

    Version 0.5 (WORK-6) — per-slot driver-count observability. Every
    `SlotRequest` gains an optional `maxDriversPerSlot` (integer 1-2000,
    default 4). `/check` 200 responses carry an always-present
    `X-Slot-Defence-Trimmed-Count` response header equal to the total
    number of hub-eligible drivers exceeding each slot's declared cap
    (`sum(max(0, driversInHub - cap))` across the request's slots), so
    consumers get a stable, wire-visible signal of over-capacity load
    without having to parse the decisions array. The value is
    observability-only in v0.5 — the optimiser does not trim; feed it
    back to your dispatch/planning layer.


    v0.6 (WORK-7 Slice 4) — Prometheus `GET /metrics` endpoint added.
    Serves the process's in-memory counters + histograms in Prometheus
    text exposition format (`text/plain; version=0.0.4`). Unauthenticated
    per Prometheus scrape convention. Metric families: `check_requests_total`,
    `check_decisions_total{outcome,reason}`, `over_capacity_total`,
    `osrm_calls_total{status}`, `check_duration_seconds` (histogram)
    — all prefixed `slot_defence_` — plus prom-client default runtime
    series. Counters reset on process restart. WORK-7 Slice 4 also
    closed GitHub issue #5 by extracting the WORK-6 over-capacity
    compute out of the controller into a pure service — no wire change,
    header + pino event invariants preserved.


    v0.7 (WORK-7 Slice 5) — service rebranded from "Slot Defence" to
    "Slotster AI" across all user-visible surfaces; hosted at
    `https://slotsterai.com` (primary; `slotsterai.co.uk` 301-redirects;
    `slotster-ai.fly.dev` remains as a Fly-provided fallback). Supporting
    demo-evaluation endpoints added: `GET /` (public landing page with
    6 preset scenario buttons), `GET /demo/config` (returns plaintext
    demo API key for the landing page — public by design, friction
    filter, not security boundary), `GET /openapi` (raw YAML self-serve).
    Internal identifiers (`slot-defence` codebase name, `package.json`
    name, `pino` `base.service` field, factory-pack paths, git repo,
    code file names) are deliberately unchanged.


    v0.7.3 (WORK-8) — three visual demo surfaces built on top of the
    v0.7 landing, all consuming the same public `/check` endpoint:
    `GET /demo/customer.html` (Slotster-AI-branded mock slot picker with
    prose reason chips), `GET /demo/cockpit.html` (internal cockpit —
    Leaflet map with OSM tiles + drivers/vendor/customer pins + catchment
    rings + anchor polyline; per-slot decision panel with reason enum +
    prose + latency + trimmed-count), `GET /demo/api.html` (raw JSON
    scenarios demo relocated from the v0.7 landing). Supporting demo
    routes added: `GET /demo/reasons.json` (customer-facing prose keyed
    by the 12 reason codes), `GET /demo/scenarios.json` (shared six-
    preset source), `GET /demo/postcode-centroids.json` (bundled London
    postcode zone → centroid map for the cockpit's customer pin
    lookup). Every demo surface uses the same public demo API key from
    `GET /demo/config` and shares `public/demo/styles.css` +
    `public/demo/lib.js`. Cockpit adds two external client-side
    dependencies (Leaflet 1.9.4 via unpkg CDN, OSM raster tiles via
    tile.openstreetmap.org); both honest-degrade to visible error states
    if unavailable (see `docs/deployment-rollback-runbook.md § OSM tile
    dependency`).


    v0.8.0 (WORK-9) — permissive slot-capacity mode for slot-first
    marketplaces (Cotta model). New optional top-level `slotCapacityMode:
    "strict" | "permissive"` on `CheckRequest`, defaulting to `"strict"`
    (every existing WORK-7 caller unchanged — full BC). New optional
    per-`SlotRequest` `slotCapacityRemaining: integer(min 0)` — required
    per slot when `slotCapacityMode="permissive"` (the controller returns
    400 with a slot-specific message otherwise; fail-loud per Rule 4).
    In permissive mode the accept/reject test switches from
    driver-readiness to slot-level capacity: `slotCapacityRemaining>0` →
    `decision: "available"`; `<=0` → `decision: "unavailable"` with new
    reason `slot_capacity_full`. Slot-window-missed still rejects (time
    is authoritative). Every decision (BOTH modes) now carries two new
    advisory response fields: `driverReadyNow: boolean` (required — true
    iff strict-mode logic would have accepted the slot on driver grounds)
    and `expectedDispatchType: "anchor-now" | "route-later" | "batching"`
    (optional — omitted on Unavailable decisions; on Available: `batching`
    for zero-detour anchor or activeAnchorMatch, `anchor-now` for
    route-insertion, `route-later` when permissive accepts without a
    driver-ready anchor). Permissive mode still runs the full WORK-4
    OSRM/anchor optimisation so callers get the batching insight
    (anchorDriverId, detourMinutes) intact — that's the remaining USP for
    slot-first marketplaces. Strict marketplaces continue to get the full
    overpromising-prevention. Reason enum grows 12 → 13
    (`slot_capacity_full`).


    v0.9.0 (WORK-10 Slice 1) — pluggable dispatch-provider layer,
    request-side foundation. New optional top-level `dispatchProvider:
    string` on `CheckRequest` (lowercase-kebab pattern) letting a
    marketplace declare which registered provider should fulfil the slot
    when it commits. Marketplace-scoped registry: providers are declared
    globally via `DISPATCH_PROVIDER_KEYS` + per-provider env-var groups
    (endpoint, HMAC secret, timeout); marketplaces map their caller-id
    (last 8 hex of the X-API-Key SHA-256 digest) to an entitled provider
    list + default via `DISPATCH_MARKETPLACE_*` env vars. Feature-flag
    gated by `FEATURE_DISPATCH_PROVIDERS` (default off) — every existing
    WORK-9 caller is silently BC. Two new error causes on `/check`:
    `dispatch_provider_unknown` (400) when the caller sends a
    `dispatchProvider` key not registered in the global registry;
    `dispatch_provider_not_entitled` (422) when the caller sends a key
    that exists globally but is not in their marketplace's allowlist, OR
    when the caller's caller-id is not registered under any marketplace.
    Slice 1 is a **dark surface**: no outbound POST fires yet, no
    inbound webhook is mounted; the request-side contract + registry
    ship so integrators can start wiring their env-var config. Slice 2
    lands the outbound POST on `/slots/events activate` (with
    `dispatchStatus` in the response) + a Nash-nominal in-repo dev
    fixture; Slice 3 lands the inbound `POST /dispatch/events` webhook
    with HMAC signature auth + event-id idempotency + the four
    `dispatch.*` event handlers on anchor state.


    v0.9.1 (WORK-10 Slice 2) — outbound half of the dispatch loop.
    `/slots/events activate` now fires an outbound `POST /dispatches`
    to the resolved provider (when `FEATURE_DISPATCH_PROVIDERS=true`
    and the caller is registered under a marketplace). Slotster mints a
    UUIDv4 `dispatchId` and stamps it into the response body regardless
    of outbound outcome; provider echoes it in the 2xx body. Response
    also carries `dispatchStatus: "created" | "pending" | "failed"` —
    `pending` = provider unreachable / timeout; `failed` = provider
    non-2xx / parse-error / dispatchId echo mismatch. Activation NEVER
    hard-fails on outbound provider error — the local anchor state
    always commits. Symmetric on release: `/slots/events released`
    events with a caller-echoed `dispatchId` (optional) fire an
    outbound `DELETE /dispatches/{id}` with the same fail-soft posture.
    A Nash-nominal in-repo dev fixture (`POST /dev/nash-nominal/dispatches`
    + `DELETE /dev/nash-nominal/dispatches/{id}`) ships alongside — only
    mounted when `NODE_ENV !== "production"` — to serve as the outbound
    target in local dev + CI. The provider-facing contract publishes
    separately at `openapi/slotster-dispatch-provider.openapi.yaml`.


    v0.10.0 (WORK-10 Slice 3) — inbound half + WORK-10 complete. New
    `POST /dispatch/events` webhook endpoint that dispatch providers
    (Nash, Crowfly, or any implementer of the v1.0.0 provider contract)
    post lifecycle events to. HMAC-SHA256 authentication via three
    request headers (`X-Dispatch-Provider-Key`,
    `X-Dispatch-Timestamp`, `X-Dispatch-Signature`) with a 5-minute
    timestamp skew guard; event-id idempotency via Redis `SET NX` on
    `dispatch:evt:v1:<providerKey>:<event_id>` (TTL from
    `DISPATCH_EVENT_ID_TTL_MS`, 7d default). Four event types handled:
    `dispatch.status_changed` (log-only), `dispatch.route_updated`
    (SETEX on `dispatch:eta:v1:<providerKey>:<dispatchId>`; read
    consumer deferred to WORK-11), `dispatch.completed` (invokes
    `anchorStore.release()` with WORK-5 orderId guard authoritative),
    `dispatch.failed` (log-only; marketplace remains authoritative via
    `/slots/events released`). Fail-soft on Redis outage — never a 5xx
    to the provider. Only mounted when `FEATURE_DISPATCH_PROVIDERS=true`
    (dark surface otherwise; strict-mode BC preserved). Provider
    contract at `openapi/slotster-dispatch-provider.openapi.yaml` bumps
    to v1.0.0 — full stability commitment for both halves of the loop.
    WORK-10 pluggable dispatch-provider layer complete.


    v0.12.0 (WORK-15 Slice 1) — pre-book clustering capability. New optional
    top-level `pendingOrderDistribution` on `CheckRequest`: a nested map
    `{[sector]: {[hour]: count}}` reporting the caller's already-booked
    pre-booked orders per postcode sector per clock-hour, plus a required
    sibling `pendingOrderDistributionTimezone` (currently the enum
    `"Europe/London"` only — the caller derives the cell key once at
    aggregation from UTC-stored timestamps so Slotster stays timezone-pure).
    When present with `FEATURE_PREBOOK_CLUSTERING=true`, the optimiser
    FLAGS slots whose zone+hour cell already carries N or more existing
    orders (config-tunable per consumer via `PREBOOK_CLUSTER_CONFIG_JSON`,
    default N=1); flagging stops once the cell exceeds M orders
    (default M=5).

    IMPORTANT — Slotster does NOT reorder or re-score `decisions[]`.
    The array is returned in `slotRequests` input order, always, so callers
    can correlate positionally. Ranking on the basis of clustering is the
    CONSUMER's decision: use the `cluster` flag below to order, badge or
    ignore clustered slots as suits your surface. (Earlier wording here
    claimed the optimiser "biases slot ranking"; it never did — see
    `scenarios/cluster-ranking-bias-equivalence.json`, which asserts the
    order-preserving behaviour. Corrected in v0.17.0.)

    Every `SlotDecision` carries three optional advisory response fields:
    `cluster: boolean` (this slot's zone+hour cell qualifies as a cluster
    given the caller's distribution), `appliedBias: boolean` (this slot
    qualified AND was available — i.e. it is a candidate the consumer may
    wish to rank up; it does not indicate Slotster changed anything),
    and `clusterSuppressionReason` (present when cluster is false with a
    distribution attached; enum `"below_n" | "cell_over_m" |
    "eligibility_filtered"` explains why the slot did not qualify).
    Absent-field / malformed / clustering-throws all degrade gracefully to
    today's response (no flags, no suppression reason). Feature
    flag `FEATURE_PREBOOK_CLUSTERING` defaults false — every existing
    WORK-10 caller is silently BC. Payload minimisation is contract-level:
    the distribution carries aggregate counts per zone per hour only; the
    Joi validator rejects any additional keys and rejects any timezone
    value other than `"Europe/London"` with 400. Vendor concentration
    handling lives Cotta-side (single-vendor cap enforced during
    distribution assembly, not by Slotster) — Slotster never sees vendor
    identity in this payload.

    Version 0.24.0 (WORK-76 Slice 1) — zone-scoped delivery-day validity
    precheck. `GET /slots/precheck` answers "is there any bookable
    volume-defence capacity left in this zone on this day" as a cheap,
    pure-read, caller-scoped signal a consumer can show before checkout.
    Dark surface behind `FEATURE_ZONE_VALIDITY_SIGNAL` (default off).
    WORK-76 also ships the push half of the same capability: when a
    `(zone, window)` cell genuinely closes or reopens, Slotster fires a
    signed, at-most-once outbound webhook to a configured consumer
    endpoint, behind `FEATURE_FLIP_TRIGGERED_PUSH` (default off). That
    call is outbound-only and so has no path in this document — the
    receiver-facing contract (headers, HMAC scheme, payload, delivery
    posture) lives in `docs/consumer-integration.md § Flip-triggered
    push`.

    Version 0.26.0 (WORK-83) — `GET /slots/precheck` judges a day only over
    the business's deliverable hours (`slots.deliveryWindow` minus
    `slots.disabledHours`, Europe/London, on the London calendar day of
    `day`; a business with neither setting keeps the previous all-hours
    scan), and adds `data.openHours` — the deliverable hours whose
    volume-defence window still has headroom. Additive: consumers of an
    older Slotster that omits `openHours` treat it as unknown.

    Version 0.27.0 (WORK-90) — `POST /reserve` runs the same fleet-feasibility
    verdict as `/check` (behind `FEATURE_FLEET_FEASIBILITY`) and can answer
    `reserved: false` with the new reason `fleet-infeasible`. The request gains
    an optional `customerLocation`; without it the postcode district's centre
    is used. Additive: a consumer that treats any `reserved: false` as "choose
    another slot" needs no change.

    Version 0.28.0 (WORK-93) — `slotCapacityRemaining` on `/check` is
    caller-relative for a caller with a live hold: with
    `FEATURE_CHECK_EXCLUDES_OWN_HOLD` on and `holdOrderId` sent, the caller's
    own hold is no longer counted in the number returned to them for the
    hold's cell. No shape change; decisions, stored counts and every other
    caller's number are unchanged.
  license:
    name: Unlicensed — personal repo
servers:
  - url: https://slotsterai.com
    description: Production (Fly.io LHR, WORK-7)
  - url: https://slotster-ai.fly.dev
    description: Production (Fly-provided fallback URL)
  - url: http://localhost:3000
    description: Local dev
paths:
  /health:
    get:
      summary: Liveness probe
      description: >-
        Liveness only — answers "is this process running?" Returns 200
        unconditionally as long as the HTTP server is up. Readiness (with
        OSRM connectivity) lives at `/ready`.
      responses:
        "200":
          description: Service is up
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
  /ready:
    get:
      summary: Readiness probe (OSRM + Redis connectivity)
      description: >-
        Answers "can this process handle traffic right now?" Issues
        short-budget probes to BOTH the primary OSRM (500 ms route call
        against a fixed London pair) AND Redis (500 ms PING) in parallel.
        Returns 200 iff both dependencies reach within budget. Returns 503
        on any failure of either — the response body always carries both
        `osrm` and `redis` legs so the caller can see which failed.
        Downstream outage is not an internal error, so no 500 is emitted.
      responses:
        "200":
          description: Both OSRM AND Redis reachable within budget.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadyResponse"
        "503":
          description: OSRM or Redis (or both) unreachable, slow, or misbehaving.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadyResponse"
  /metrics:
    get:
      summary: Prometheus metrics (exposition format)
      description: >-
        Returns the process's in-memory counters + histograms in Prometheus
        text exposition format (`text/plain; version=0.0.4`). Unauthenticated
        by design — Prometheus scrapers don't authenticate, and the metric
        values (aggregate request rates, decision reason breakdowns, OSRM
        outcome counts, latency histograms) contain no PII, no customer
        order content, and no caller identity. Counters reset on process
        restart (standard Prometheus behaviour).

        Metric families exposed (all prefixed `slot_defence_`):
        `check_requests_total` (counter),
        `check_decisions_total{outcome, reason}` (counter, WORK-4 12-code
        reason enum),
        `over_capacity_total` (counter, sum of trimmed driver-slots),
        `osrm_calls_total{status: ok|error|timeout}` (counter),
        `check_duration_seconds` (histogram, 10 ms – 2 s buckets tuned to
        the WORK-1 NFR), plus prom-client's default Node.js runtime series
        (event loop lag, GC, memory, CPU) also prefixed `slot_defence_`.
      security: []
      responses:
        "200":
          description: Prometheus text exposition format.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            text/plain:
              schema:
                type: string
                example: |
                  # HELP slot_defence_check_requests_total Total number of POST /check requests handled successfully.
                  # TYPE slot_defence_check_requests_total counter
                  slot_defence_check_requests_total 42
  /demo/reasons.json:
    get:
      summary: Customer-facing prose for each reason code
      description: >-
        Returns a JSON object keyed by every reason enum value in
        `src/services/reasons.ts` — the key set is exactly the `Reason`
        union, so it grows whenever a code is added and is never a
        subset. Each value is a customer-facing
        sentence explaining what the code means in plain English.
        Rendered as chips in the customer mock slot picker (WORK-8
        Slice 2) and beside the reason code in the internal Slotster AI
        cockpit (WORK-8 Slice 3). Unauthenticated by design (same
        posture as `/demo/config`, WORK-7). `Cache-Control:
        public, max-age=300`.
      security: []
      responses:
        "200":
          description: Reason-code prose map.
          content:
            application/json:
              schema:
                type: object
                description: >-
                  Object keyed by every `Reason` enum value; each value is
                  a customer-facing sentence.
                additionalProperties:
                  type: string
                example:
                  anchor_in_range_with_capacity: "A driver in the area has space to take this delivery."
                  driver_at_capacity: "The nearest driver is already carrying their maximum number of deliveries."
  /demo/postcode-centroids.json:
    get:
      summary: Bundled London postcode zone centroids for the cockpit map
      description: >-
        Returns a JSON object keyed by London postcode district (e.g.
        `SE1`, `SW11`); each value is `{lat, lng, name}` at the district
        centroid. Backed by `src/data/london-postcode-centroids.json` —
        the same source the server-side optimiser uses via
        `src/utils/postcode.ts`. Consumed by the WORK-8 Slice 3 cockpit
        (`/demo/cockpit.html`) to plot the customer pin from
        `customerPostcodeZone`. Unauthenticated per demo posture;
        `Cache-Control: public, max-age=3600` (1-hour cache — the data is
        bundled and only changes with a redeploy).
      security: []
      responses:
        "200":
          description: Postcode zone centroid map.
          content:
            application/json:
              schema:
                type: object
                description: Object keyed by London postcode district code.
                additionalProperties:
                  type: object
                  required: [lat, lng, name]
                  properties:
                    lat: { type: number, format: double }
                    lng: { type: number, format: double }
                    name: { type: string }
                example:
                  SE1: { lat: 51.5040, lng: -0.0900, name: "Borough / Bermondsey" }
                  SW11: { lat: 51.4720, lng: -0.1670, name: "Battersea / Clapham Junction" }
  /demo/volume-defence-state:
    get:
      summary: Live volume-defence state for the demo caller (WORK-14 Slice 0)
      description: >-
        Returns the current cap / count / trip status for the demo caller
        at a given postcode zone in the current hour window. Consumed by
        the WORK-14 cockpit Volume defence panel to render a live-updating
        view of WORK-12 volume-defence enforcement. When `tripped === true`,
        the response includes a `blockKit` field carrying the Slack Block
        Kit payload the real WORK-13 Slack path would post — the panel
        renders this in-page as a "mock Slack thread" (no real Slack
        workspace involved). Only mounted when `FEATURE_VOLUME_DEFENCE=true`
        (WORK-82: mounted whenever the flag is on); returns 404 when the demo
        caller (`sha256(DEMO_PUBLIC_KEY_PLAINTEXT).slice(-8)`) has no
        volume-defence caps in its business config, so the panel hides.
        Unauthenticated by design — matches other `/demo/*` endpoints.
      security: []
      parameters:
        - name: zone
          in: query
          required: true
          description: London postcode district (e.g. `SW1A`, `SE1`).
          schema:
            type: string
            pattern: "^[A-Z]{1,2}[0-9]{1,2}[A-Z]?$"
        - name: hourWindow
          in: query
          required: false
          description: >-
            **WORK-14 Slice 2.** Optional UTC ISO datetime on an exact hour
            boundary (e.g. `2026-07-16T10:00:00.000Z`). When omitted, the
            server uses the current hour (Slice 0 behaviour). When present,
            the state is read for the specified hour — the cockpit panel's
            prev / current / next picker uses this to preview past or future
            hours (both return empty state when no Redis entries exist for
            that key). Malformed values or off-boundary values return 400.
          schema:
            type: string
            format: date-time
            pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00:00(\\.000)?Z$"
      responses:
        "200":
          description: Current VD state for the demo caller at the requested zone.
          content:
            application/json:
              schema:
                type: object
                required: [cap, count, tripped]
                properties:
                  cap:
                    type: integer
                    description: Effective cap (override if set, else configured).
                  count:
                    type: integer
                    description: Current count in the hour window (0 if none).
                  tripped:
                    type: boolean
                    description: True iff the cap has been tripped this hour.
                  capOverride:
                    type: integer
                    description: Present only if an override is set via reopen.
                  blockKit:
                    type: array
                    description: Slack Block Kit payload; present only when `tripped === true`.
                    items:
                      type: object
              example:
                cap: 3
                count: 3
                tripped: true
                blockKit:
                  - { type: header }
                  - { type: section }
                  - { type: context }
                  - { type: actions }
        "400":
          description: >-
            Bad request. Causes: `demo_vd_zone_missing`, `demo_vd_zone_malformed`
            (Slice 0); `demo_vd_hourwindow_malformed`,
            `demo_vd_hourwindow_not_on_hour_boundary` (Slice 2 — non-parseable
            or off-hour `hourWindow` respectively).
        "404":
          description: Demo caller's business config sets no volume-defence caps, or the zone has no cap. Panel gracefully hides.
  /demo/scenarios.json:
    get:
      summary: Shared preset-scenarios source-of-truth for the demo surfaces
      description: >-
        Returns `{presets: DemoScenario[]}` — six canned `/check` request
        payloads with year-2099 timestamps so the demo stays evergreen.
        The WORK-7 landing (`GET /`), the WORK-8 customer view
        (`/demo/customer.html`, Slice 2) and the WORK-8 cockpit
        (`/demo/cockpit.html`, Slice 3) all fetch this list so their
        preset buttons stay in lock-step. Each preset's `request` field
        matches the `CheckRequest` shape POSTed to `/check`.
        Unauthenticated (same posture as `/demo/config`, `/demo/reasons.json`).
        `Cache-Control: public, max-age=300`.
      security: []
      responses:
        "200":
          description: Shared preset scenarios list.
          content:
            application/json:
              schema:
                type: object
                required: [presets]
                properties:
                  presets:
                    type: array
                    items:
                      type: object
                      required: [id, label, kind, kindLabel, request]
                      properties:
                        id:
                          type: string
                          description: Kebab-case identifier (matches the button's `data-scenario`).
                          example: "anchor-in-range"
                        label:
                          type: string
                          description: Human-readable button label.
                          example: "Anchor in range"
                        kind:
                          type: string
                          enum: [accept, reject]
                          description: Expected outcome for this scenario.
                        kindLabel:
                          type: string
                          description: Display badge above the label (e.g. "Accept · batching").
                          example: "Accept"
                        request:
                          description: Full `/check` request body — same shape as the CheckRequest schema below.
                          type: object
  /demo/config:
    get:
      summary: Demo API key (public-by-design landing page config)
      description: >-
        Returns the plaintext demo API key so the WORK-7 Slice 5 landing
        page's preset scenario buttons can include it on `X-API-Key`
        when POSTing to `/check`. The plaintext is view-source-visible
        by design — it's a friction filter, not a security boundary
        (see the shape-brief; the WORK-5 per-caller rate limit is the
        safety net). Unauthenticated. Rotate the plaintext together
        with the corresponding hash in `API_KEY_HASHES` — see
        `docs/deployment-rollback-runbook.md § Demo key rotation`.
      security: []
      responses:
        "200":
          description: Plaintext demo API key.
          content:
            application/json:
              schema:
                type: object
                required: [apiKey]
                properties:
                  apiKey:
                    type: string
                    description: Plaintext demo API key.
                    example: "2915aa3c8ff6f8d272725f1c8a6f7103fa6180c8084513b66ac414e9c076b335"
  /openapi:
    get:
      summary: Raw OpenAPI contract (self-serve)
      description: >-
        Returns this OpenAPI YAML file verbatim over `text/yaml`.
        Unauthenticated. Same content as
        `openapi/slot-defence.openapi.yaml` in the repo. WORK-7 Slice 5.
      security: []
      responses:
        "200":
          description: Raw OpenAPI YAML.
          content:
            text/yaml:
              schema:
                type: string
                description: YAML source of this contract.
  /check:
    post:
      summary: Evaluate slot requests against real-time capacity
      description: >-
        Per-request stateless for the routing / batching / anchor
        decision — the full capacity picture (drivers, active routes,
        vendor catchments) is passed in every call. An optional
        `nowIso` may be supplied so scenario-style callers can pin the
        window-comparison clock; production callers omit it and get
        server time.


        Volume-defence gating. When `FEATURE_VOLUME_DEFENCE=true` AND
        the caller's business config caps the requested slot's zone (or
        sets a default cap), `/check`
        compares the stored per-`(callerId, zone, hourWindow)` count
        against that cap and returns the slot as `unavailable` with
        `reason: slot_capacity_full` when the cap is met.


        **`/check` is PURE-READ — it does not reserve.** Changed in
        SLOT-1 Slice 22 (`slot-defence#56`): the gate previously INCRed
        the counter for every `available` decision, so one call
        evaluating N slots consumed N units of capacity and a shopper
        who merely reopened the picker burned the zone-hour down.
        Evaluating slots now consumes nothing, there is no ~60-second
        reserve TTL, and there is no `DECR`-back. If you integrated
        against the old semantics, drop any compensating logic.


        Capacity is consumed by exactly two writes: `POST /reserve`
        (the 5-minute checkout hold, `FEATURE_SLOT_RESERVE`) and
        `POST /slots/events order_committed` (the durable claim). They
        share an `orderId` dedup key, so a hold followed by its own
        commit counts once. **If you need a slot held between offering
        it and committing the order, call `/reserve` — a successful
        `/check` gives you no claim on anything.**


        Callers whose business config sets no volume-defence caps, and
        slots whose zone is not in the business's caps map (with no
        `defaultCap`), are unaffected —
        the gate short-circuits with no Redis I/O (SC-5 composability).


        Business configs are cached per process; a reload costs a request
        at most `CONFIG_LOAD_DEADLINE_MS` (100ms by default) and otherwise
        finishes in the background. When Postgres is slow or down, `/check` keeps
        enforcing the last config it loaded. Only when business configs
        have never loaded in this process (Postgres unreachable since boot)
        does it fail open: caps are not enforced, and the request is
        counted in `slot_defence_volume_defence_config_unavailable_total`.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckRequest"
      responses:
        "200":
          description: Decisions for every slot in the request
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-Slot-Defence-Trimmed-Count:
              $ref: "#/components/headers/XSlotDefenceTrimmedCount"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckSuccess"
        "400":
          description: >-
            Request validation failed. `details.cause` variants include
            `dispatch_provider_unknown` (WORK-10 v0.9.0) when the request
            carries a `dispatchProvider` key that is not registered in
            the global `DISPATCH_PROVIDER_KEYS` registry; Joi-style
            details array for schema-shape violations otherwise.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: >-
            Missing or invalid `X-API-Key` header. `details.cause` is
            `auth_missing_key` or `auth_invalid_key` respectively; neither
            message leaks anything about the valid key set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: Payload too large (>100kb)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: >-
            Semantic validation failed. WORK-10 (v0.9.0) introduces
            `details.cause = "dispatch_provider_not_entitled"`, which
            fires when the request carries a `dispatchProvider` key that
            exists globally but is not in the caller's marketplace
            allowlist, OR when the caller's caller-id (last 8 hex of the
            SHA-256 digest of the matched X-API-Key) is not registered
            under any marketplace at all.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: >-
            Per-caller rate limit exceeded (WORK-5 Slice 2). Token bucket
            keyed by matched X-API-Key digest; `Retry-After` header
            carries the integer number of seconds until the next request
            will be admitted. `details.cause` is `rate_limit_exceeded`.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /slots/events:
    post:
      summary: Record an active-anchor lifecycle event
      description: >-
        State-write endpoint that lets a caller (typically Cotta's checkout
        commit-path) tell Slotster AI which driver has been anchored to
        which slot, and later that the anchor has been released. Body
        carries a `type` discriminator ("activated" | "released"). For
        `activated`, Slotster AI writes StoredAnchor JSON to Redis keyed
        by anchorDriverId, with a TTL derived from the caller-supplied
        `validUntil`. For `released`, Slotster AI performs a guarded
        delete: the entry is removed only if its stored orderId matches
        the caller's orderId, protecting fresh state from a delayed
        release event for a superseded order. No stored state OR
        orderId-mismatch = idempotent no-op returning stored:false.

        Redis-outage on this write path returns 502 with the standard
        `ErrorEnvelope`. Silent success would leave the caller believing
        state was durable when it wasn't (Rule 4). This differs from
        /check's Fallback-Active read posture; writes cannot fall back.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SlotEventRequest"
      responses:
        "200":
          description: >-
            Wire operation succeeded. `stored` in the body carries the
            business outcome (true on successful activate; true on
            matched release; false on release-with-no-match).
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SlotEventSuccess"
        "400":
          description: >-
            Request validation failed (bad type discriminator; missing
            required field per branch; forbidden extra field on released;
            validUntil in the past).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: >-
            Missing or invalid `X-API-Key` header. `details.cause` is
            `auth_missing_key` or `auth_invalid_key` respectively.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          description: >-
            WORK-5 Slice 3 (FU-3) — activate orderId conflict. A
            different `orderId` already holds the anchor on the same
            `(customerPostcodeZone, anchorDriverId, slotId)` triple.
            `details.cause` is `activate_conflict`. The response does
            NOT leak the existing orderId. Consumers must NOT retry;
            same-orderId re-activate is 200 idempotent instead. See
            `docs/consumer-integration.md § Activate conflict` for the
            release-then-activate supersede flow.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: Payload too large (>100kb)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: >-
            Per-caller rate limit exceeded (WORK-5 Slice 2). Token bucket
            keyed by matched X-API-Key digest; `Retry-After` header
            carries the integer number of seconds until the next request
            will be admitted. `details.cause` is `rate_limit_exceeded`.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "502":
          description: >-
            Active-anchor store (Redis) unreachable. The write did not
            happen; the caller must retry or fail the commit path.
            `details.cause` carries the concrete ioredis error string.
            Also returned for `order_committed` when volume defence is on
            and the business config plane (Postgres) has never loaded in
            this process, so whether the order must be counted is unknown
            (`details.cause: business_config_unavailable`). This 502 is
            returned within about `CONFIG_LOAD_DEADLINE_MS` (100ms by
            default). Retrying with the same `orderId` is safe — the count
            is deduplicated on it — so keep retrying until the order's
            `dropoffEndTime`; once the delivery is over, a late count no
            longer protects any cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /slots/precheck:
    get:
      summary: Zone-scoped delivery-day validity precheck (WORK-76 Slice 1)
      operationId: slotsPrecheck
      description: >-
        Caller-scoped, pure-read endpoint answering "is there any bookable
        volume-defence capacity left in this zone on this day" — a cheap
        early signal a consumer can show before checkout, instead of only
        discovering a dead day at the basket.


        A day is valid unless every volume-defence window (per
        `VD_WINDOW_HOURS`) that a deliverable hour of that day falls in is
        at or over its effective cap. Deliverable hours are the ones
        `/check` would offer a slot in: the business's published
        `slots.deliveryWindow` (Europe/London wall-clock, `startHour`
        inclusive, `endHour` exclusive) minus its `slots.disabledHours`,
        on the London calendar day of `day` (WORK-83). Windows outside
        those hours never make a day valid. A business with neither
        setting has every hour deliverable, and the scan is unchanged from
        before WORK-83. A caller with no volume-defence config, or a zone
        not governed by it, is always valid — matching `/check`'s own
        "ungoverned" behaviour.


        Deliberately does NOT invoke the same Lua path `/check`'s
        volume-defence gate uses, which has side effects on a fresh cap
        breach (sets the sticky temp-close flag + the SC-4 trip-cooldown
        key). This endpoint reads the same underlying Redis state via
        pure-read paths only, so a precheck call can never itself trip a
        zone closed or consume the one-time SC-4 alert meant for a real
        checkout.


        Resolves the caller's volume-defence config per business (WORK-82),
        the same way `/check` does: a business's published caps, default
        cap and group caps govern its answer, and a business config that
        turns volume defence off reads as not configured (always valid).


        Dark surface: only mounted when `FEATURE_ZONE_VALIDITY_SIGNAL=true`.
        With the flag off, the route returns 404 via the app-level
        catch-all.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: zone
          in: query
          required: true
          schema:
            type: string
            pattern: "^[A-Z]{1,2}[0-9]{1,2}[A-Z]?$"
          description: Postcode zone, matching /check's customerPostcodeZone vocabulary.
        - name: day
          in: query
          required: true
          schema:
            type: string
            format: date
          description: >-
            The delivery day to check. Send a date-only value
            (`2026-09-10`); it is normalised to UTC midnight. For a
            business with a delivery window or disabled hours, the
            deliverable hours are taken on the Europe/London calendar day
            that instant falls in — so London midnight
            (`2026-09-09T23:00:00Z` in BST) and date-only both mean
            10 September. For a business with neither, the scan covers the
            24 hours from that instant, as before; a value that is not UTC
            midnight then scans from that instant rather than the calendar
            day.
      responses:
        "200":
          description: Precheck computed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SlotsPrecheckResponse"
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { $ref: "#/components/responses/Auth401" }
        "429": { $ref: "#/components/responses/RateLimit429" }
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "503":
          description: >-
            The capacity state backing the signal could not be read (a
            delivery-count read failed), or the caller's business config
            could not be loaded (Postgres has never answered this process;
            returned within about `CONFIG_LOAD_DEADLINE_MS`).
            A failed cap-override read alone does not produce a 503 — that
            window is judged against the configured cap, as `/check` does.
            No `valid` verdict is returned rather than a fabricated
            one — the consumer should treat the day as un-annotated and fall
            through to `/check`, which remains the enforcement point.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /reserve:
    post:
      summary: Place a 5-minute checkout hold on a slot (WORK-50 Slice 2a + Slice 9)
      description: >-
        Dark surface — mounted only when `FEATURE_SLOT_RESERVE=true`; with the
        flag off the route returns 404 via the app-level catch-all.


        Atomically claims one unit of the caller's volume-defence zone-hour
        capacity for the given `orderId`, at CHECKOUT — before the order
        commits. This closes the double-book race `/check` cannot: `/check` is
        pure-read (it compares the count against the cap and reserves nothing),
        and `/slots/events order_committed` only lands once the order exists,
        so two shoppers could both be offered the last slot in a
        `(caller, zone, hourWindow)` cell. `/reserve` is the claim in between.


        **The claim is a HOLD, not a booking.** The reservation lives for
        **300 seconds (5 minutes)**, capped so it can never outlive the slot
        itself: `ttl = min(300, seconds until dropoffEndTime)`. On expiry the
        underlying keys TTL out and the capacity silently returns to the pool —
        an abandoned checkout self-heals with no compensating call. Call
        `/release` to return it sooner.


        **Deduped by `(caller, orderId)` on the SAME dedup key `/slots/events
        order_committed` uses**, so a reserve followed by its own commit counts
        exactly ONCE. A repeat `/reserve` for an `orderId` that already holds a
        claim (or that has already committed) is idempotent: it returns
        `reserved: true` with current headroom and performs no second
        increment. Note that the idempotent path does **not** extend the
        underlying hold — `holdExpiresAt` on such a response is computed from
        the request instant and may therefore be LATER than the real expiry.
        Treat it as an upper bound on a re-hold, not a guarantee.


        **`reason: unmanaged` means the request was outside the capability's
        remit and nothing was claimed** — there is no double-book to prevent,
        exactly as the volume-defence gate leaves such slots untouched. Three
        causes, indistinguishable on the wire: (a) `pincode` does not reduce to
        a postcode zone; (b) the authenticated caller's business config sets
        no volume-defence caps, or no cap for that zone and no default cap; (c)
        `dropoffEndTime` is already in the past. `reserved` is `true` in all
        three — the cap check never withholds an unmanaged slot (the WORK-90
        fleet check below runs first and can still refuse it). Cause (b) also covers a
        process whose business configs have never loaded (Postgres
        unreachable since boot): the hold fails open as `unmanaged` rather
        than refusing checkout. Once configs have loaded, a slow or
        unreachable Postgres does not change the answer — the last loaded
        config keeps applying.


        **`FEATURE_SLOT_RESERVE` alone is not enough to get a hold.** Cause (b)
        above includes the whole-service case: with `FEATURE_VOLUME_DEFENCE=false`
        no caller is governed, whatever its business config says, so this endpoint mounts,
        authenticates, returns 200 — and holds nothing, every time, with
        `reason: unmanaged`. A real hold needs `FEATURE_SLOT_RESERVE=true` **and**
        `FEATURE_VOLUME_DEFENCE=true` **and** the caller's business config
        capping the zone (or setting a default cap). Assert on
        `reason == "reserved"`, never on the 200 or on `reserved: true`, if you
        need to know a claim actually exists.


        Cap resolution reads the same per-business config the
        volume-defence gate reads (the caller's business's published
        `volumeDefence.caps` / `defaultCap` / group caps,
        with a live `volume-defence:cap-override:*` from a `/volume-defence/decisions`
        reopen taking precedence), so `/check`, `/reserve` and the cap trip all
        agree on the number.


        Scope: the zone-hour COUNT, plus the fleet-feasibility check. Cluster
        reservation and adjacency stay offer-time in `/check`.


        WORK-90 — when `FEATURE_FLEET_FEASIBILITY` is on, reserve re-asks the
        same fleet question `/check` asks: would serving this customer in this
        window need more distinct delivery areas than the day has drivers? If
        so the hold is refused (`reserved: false`, `reason: fleet-infeasible`)
        and no zone-hour capacity is taken. A slot can reach checkout without
        passing the picker (a time pinned under one address kept after the
        address changes, or other orders landing while the basket is held), so
        the offer-time check alone let such slots be bought. The customer is
        located by `customerLocation` when sent, else by the postcode
        district's centre. Only committed orders count as areas; a live hold
        does not. When the customer cannot be located, or the day's
        committed-order index cannot be read, the fleet check is skipped and
        the reserve continues to the cap check as before.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReserveRequest"
      responses:
        "200":
          description: >-
            Reserve evaluated. Wire success — the business outcome is in
            `data.reserved` / `data.reason`. `reserved: true` on both a real
            claim (`reason: reserved`) and an unmanaged slot
            (`reason: unmanaged`); `reserved: false` when the zone-hour is at
            cap (`slot-full`), sticky-temp-closed (`temp-closed`), or the
            fleet cannot serve this customer in that window
            (`fleet-infeasible`).
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReserveSuccess"
        "400":
          description: >-
            Request validation failed (missing field; `orderId` / `pincode`
            outside its length bounds; `dropoffStartTime` or `dropoffEndTime`
            not an ISO-8601 instant; `customerLocation` not a valid
            `Coordinate`). `details` carries the Joi path/message
            pairs. Unknown properties are stripped, not rejected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: >-
            Missing or invalid `X-API-Key` header. `details.cause` is
            `auth_missing_key` or `auth_invalid_key` respectively.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Dark surface — `FEATURE_SLOT_RESERVE=false` at boot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: Payload too large (>100kb)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: >-
            Per-caller rate limit exceeded (WORK-5 Slice 2). `Retry-After`
            carries the integer seconds until the next request is admitted.
            `details.cause` is `rate_limit_exceeded`.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "502":
          description: >-
            Reservation store (Redis) unreachable or returned an unexpected
            reply. **No hold was placed.** Like `/slots/events`, this write
            path does not fall back — silent success would leave the caller
            believing the slot was held when it was not (Rule 4). The caller
            must retry or fail the checkout. `details.cause` carries the
            concrete error string.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /release:
    post:
      summary: Release a checkout hold (WORK-50 Slice 2a)
      description: >-
        Dark surface — mounted only when `FEATURE_SLOT_RESERVE=true`; with the
        flag off the route returns 404 via the app-level catch-all.


        Compensates an abandoned checkout: clears the `(caller, orderId)` dedup key and
        decrements the `(caller, zone, hourWindow)` count (floored at 0),
        returning the capacity immediately rather than waiting out the
        5-minute hold.


        **Idempotent.** A release for an `orderId` holding no reservation —
        never reserved, already released, or already expired — is a no-op
        returning `released: false`. Not an error; safe to fire
        unconditionally on checkout abandonment.


        Note the asymmetry with `/reserve`: `dropoffEndTime` is not required
        here, because the release derives its keys from `pincode` +
        `dropoffStartTime` alone.


        **A release will also clear a committed order's dedup key**, since
        `/reserve` and `/slots/events order_committed` share it. Only release
        an order you have not committed.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReleaseRequest"
      responses:
        "200":
          description: >-
            Release evaluated. `data.released` is true when a claim was
            actually cleared, false on an idempotent no-op.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReleaseSuccess"
        "400":
          description: >-
            Request validation failed (missing field; `orderId` / `pincode`
            outside its length bounds; `dropoffStartTime` not an ISO-8601
            instant). Unknown properties are stripped, not rejected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: >-
            Missing or invalid `X-API-Key` header. `details.cause` is
            `auth_missing_key` or `auth_invalid_key` respectively.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Dark surface — `FEATURE_SLOT_RESERVE=false` at boot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: Payload too large (>100kb)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: >-
            Per-caller rate limit exceeded (WORK-5 Slice 2). `Retry-After`
            carries the integer seconds until the next request is admitted.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "502":
          description: >-
            Reservation store (Redis) unreachable. The release did not happen;
            the hold stands until its TTL expires. `details.cause` carries the
            concrete error string.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /dispatch/events:
    post:
      summary: Inbound dispatch-provider webhook (WORK-10 Slice 3)
      description: >-
        Dispatch providers post lifecycle events here as the dispatch
        progresses. See the separate provider contract
        `openapi/slotster-dispatch-provider.openapi.yaml` v1.0.0 for
        the full request headers + body shape.


        HMAC-SHA256 authentication is enforced Slotster-side via the
        `hmacAuth` middleware. Only mounted when
        `FEATURE_DISPATCH_PROVIDERS=true`. Fail-soft — internal Redis
        errors do NOT bubble to a 5xx.
      parameters:
        - in: header
          name: X-Dispatch-Provider-Key
          required: true
          schema:
            type: string
            pattern: "^[a-z][a-z0-9-]*$"
        - in: header
          name: X-Dispatch-Timestamp
          required: true
          schema:
            type: string
            format: date-time
        - in: header
          name: X-Dispatch-Signature
          required: true
          schema:
            type: string
            pattern: "^[0-9a-f]{64}$"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              description: See DispatchEventBody in openapi/slotster-dispatch-provider.openapi.yaml.
              type: object
      responses:
        "202":
          description: Event accepted (fresh event_id); per-type handler ran.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      accepted: { type: boolean, enum: [true] }
                      replayed: { type: boolean, enum: [false] }
        "200":
          description: Event replayed — event_id already seen within TTL. Idempotent.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      accepted: { type: boolean, enum: [true] }
                      replayed: { type: boolean, enum: [true] }
        "400":
          description: Malformed body or Joi validation failure.
        "401":
          description: >-
            HMAC / timestamp / provider-key check failed. `details.cause`:
            `dispatch_provider_unknown`, `dispatch_timestamp_stale`,
            `dispatch_signature_missing`, `dispatch_signature_invalid`.
  /volume-defence/decisions:
    post:
      summary: Ops decision endpoint — reopen or hold-closed a tripped volume-defence slot (WORK-12 Slice 4)
      description: >-
        Slotster-ops-facing endpoint. When a volume-defence cap trips
        (see `/check` § Volume-defence enforcement), SC-4 notifies
        Slotster ops on an external channel (email via
        `FEATURE_EMAIL_ALERTS`; pino always). Ops records their
        decision here:


        - `reopen` — sets a per-(consumer, zone, hourWindow) cap
          override that supersedes the configured cap for the
          remainder of the hour, and clears the sticky temp-close
          flag. Requires `newCap` (integer, ≥1). Count is NOT reset —
          headroom is `newCap - currentCount`.


        - `hold_closed` — records the decision for audit; the sticky
          temp-close flag remains in place; no cap-override written.
          `newCap` is forbidden on this path.


        Dark surface: only mounted when `FEATURE_VOLUME_DEFENCE=true`.
        With the flag off, the route returns 404 via the app-level
        catch-all.


        Every decision is persisted as a `DecisionRecord` (fields
        `decision`, `actor`, `newCap?`, `method: "manual"`,
        `decidedAt`) in the `volume-defence:decision:*` sidecar Redis
        namespace. Persistence is the SC-4 audit trail (brief § 2
        boundary decision).


        Note on caller identity: the `callerId` in the request body
        identifies the *consumer whose cap tripped* (Cotta). The
        X-API-Key header identifies the *ops-authenticated caller*
        (recorded as `ops_caller_id` in the structured log; not
        persisted in the decision record).
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/VolumeDefenceReopenBody"
                - $ref: "#/components/schemas/VolumeDefenceHoldClosedBody"
              discriminator:
                propertyName: decision
      responses:
        "200":
          description: Decision persisted; effects applied.
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/VolumeDefenceReopenResponse"
                      - $ref: "#/components/schemas/VolumeDefenceHoldClosedResponse"
                    discriminator:
                      propertyName: decision
        "400":
          description: Joi validation failure (missing / malformed field, `newCap` on hold_closed, missing `newCap` on reopen).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: Missing or invalid X-API-Key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: Dark surface — `FEATURE_VOLUME_DEFENCE=false` at boot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  /baseline/check:
    post:
      summary: Baseline (accept-all) counterfactual — simulator use only
      description: >-
        Naïve baseline endpoint the Slice 5 simulator compares Slotster AI
        against. Accepts the identical `CheckRequest` body as `/check` and
        returns the identical `CheckSuccess` envelope, but every slot comes
        back as `decision:"available"`, `reason:"anchor_in_range_with_capacity"`,
        `detourMinutes:0`, `evaluationMode:"osrm"`, `anchorDriverId` set to
        the closest driver by Haversine (or omitted if the request has no
        drivers or the slot's vendor has no catchment). No OSRM calls, no
        capacity check, no specials check, no vehicle-range check — this IS
        the "over-promise" behaviour the brief exists to beat. **Not for
        production consumers.**
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckRequest"
      responses:
        "200":
          description: Every slot returned as available.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckSuccess"
        "400":
          description: Request validation failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          description: >-
            Missing or invalid `X-API-Key` header.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "413":
          description: Payload too large (>100kb)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          description: >-
            Per-caller rate limit exceeded (WORK-5 Slice 2). Token bucket
            keyed by matched X-API-Key digest; `Retry-After` header
            carries the integer number of seconds until the next request
            will be admitted. `details.cause` is `rate_limit_exceeded`.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "500":
          description: Internal error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
  # SLOT-1 Slice 11 — batcave read API. Four endpoints exposing the
  # persistence-plane tables (populated by /check + /slots/events +
  # /volume-defence/decisions + the internal cap-trip gate). All four
  # share query params + envelope shape; row shape varies per endpoint.
  # Consumer-scoped: caller_id is enforced from X-API-Key auth. Cross-
  # consumer reads are forbidden (query-string callerId is IGNORED if
  # it differs; a read_caller_scope_narrowed warning fires server-side).
  # Ships DARK behind FEATURE_POSTGRES_PERSISTENCE — flag off returns
  # {rows:[], nextCursor:null} on all four (same envelope, empty data;
  # no 404 so consumer code doesn't branch).
  /api/read/check-decisions:
    get:
      summary: Read historical /check decisions (batcave)
      operationId: readCheckDecisions
      security: [ApiKeyAuth: []]
      parameters:
        - $ref: "#/components/parameters/ReadSince"
        - $ref: "#/components/parameters/ReadUntil"
        - $ref: "#/components/parameters/ReadCallerId"
        - $ref: "#/components/parameters/ReadLimit"
        - $ref: "#/components/parameters/ReadCursor"
      responses:
        "200":
          description: Rows scoped to the authenticated caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadCheckDecisionsResponse"
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { $ref: "#/components/responses/Auth401" }
        "429": { $ref: "#/components/responses/RateLimit429" }
        "502": { $ref: "#/components/responses/Postgres502" }
  /api/read/slot-events:
    get:
      summary: Read historical /slots/events (batcave)
      operationId: readSlotEvents
      security: [ApiKeyAuth: []]
      parameters:
        - $ref: "#/components/parameters/ReadSince"
        - $ref: "#/components/parameters/ReadUntil"
        - $ref: "#/components/parameters/ReadCallerId"
        - $ref: "#/components/parameters/ReadLimit"
        - $ref: "#/components/parameters/ReadCursor"
      responses:
        "200":
          description: Rows scoped to the authenticated caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadSlotEventsResponse"
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { $ref: "#/components/responses/Auth401" }
        "429": { $ref: "#/components/responses/RateLimit429" }
        "502": { $ref: "#/components/responses/Postgres502" }
  /api/read/volume-defence-decisions:
    get:
      summary: Read historical /volume-defence/decisions (batcave)
      operationId: readVolumeDefenceDecisions
      security: [ApiKeyAuth: []]
      parameters:
        - $ref: "#/components/parameters/ReadSince"
        - $ref: "#/components/parameters/ReadUntil"
        - $ref: "#/components/parameters/ReadCallerId"
        - $ref: "#/components/parameters/ReadLimit"
        - $ref: "#/components/parameters/ReadCursor"
      responses:
        "200":
          description: Ops decisions scoped to the authenticated caller (as subject).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadVolumeDefenceDecisionsResponse"
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { $ref: "#/components/responses/Auth401" }
        "429": { $ref: "#/components/responses/RateLimit429" }
        "502": { $ref: "#/components/responses/Postgres502" }
  /api/read/volume-defence-state-transitions:
    get:
      summary: Read historical VD state transitions — trips + reopens (batcave)
      operationId: readVolumeDefenceStateTransitions
      security: [ApiKeyAuth: []]
      parameters:
        - $ref: "#/components/parameters/ReadSince"
        - $ref: "#/components/parameters/ReadUntil"
        - $ref: "#/components/parameters/ReadCallerId"
        - $ref: "#/components/parameters/ReadLimit"
        - $ref: "#/components/parameters/ReadCursor"
      responses:
        "200":
          description: State-transition rows scoped to the authenticated caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadVolumeDefenceStateTransitionsResponse"
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { $ref: "#/components/responses/Auth401" }
        "429": { $ref: "#/components/responses/RateLimit429" }
        "502": { $ref: "#/components/responses/Postgres502" }
  /admin/auth/request-link:
    post:
      summary: Request a magic-link sign-in email (WORK-16 admin console)
      description: >-
        Sends a single-use magic link to the email IF it is provisioned as an
        admin. The response is identical whether or not the email is provisioned
        (enumeration-safe). Dark surface — 404 when FEATURE_ADMIN_CONSOLE is off.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminRequestLinkBody" }
      responses:
        "202":
          description: Accepted — a link is sent if the address is provisioned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      status: { type: string, enum: [sent] }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "404": { description: Admin console disabled (FEATURE_ADMIN_CONSOLE off). }
  /admin/auth/verify:
    post:
      summary: Verify a magic-link token and open a session (WORK-16)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminVerifyBody" }
      responses:
        "200":
          description: Session created; Set-Cookie slotster_admin_session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminSessionView" }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: Token invalid, expired, or already used. }
        "404": { description: Admin console disabled. }
  /admin/auth/session:
    get:
      summary: Current admin session (WORK-16)
      security:
        - AdminSessionCookie: []
      responses:
        "200":
          description: The signed-in admin and their business.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminSessionView" }
        "401": { description: No valid session. }
        "404": { description: Admin console disabled. }
  /admin/auth/logout:
    post:
      summary: Destroy the admin session (WORK-16)
      responses:
        "200": { description: Logged out (idempotent); clears the session cookie. }
        "404": { description: Admin console disabled. }
  /admin/config:
    get:
      summary: Current per-business config — defaults, published, draft (WORK-16)
      security: [ { AdminSessionCookie: [] } ]
      responses:
        "200":
          description: The session business's config view.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdminConfigView" } } }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/config/draft:
    post:
      summary: Save a config override draft (WORK-16)
      description: >-
        WORK-75 — also checked against the session business's super-admin-
        authored policy (`GET /admin/super/businesses/{id}/policy`). A
        bound or hidden-section violation returns 400 with the same
        `details` array shape as any other validation failure.
      security: [ { AdminSessionCookie: [] } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/BusinessConfigOverridePayload" } } }
      responses:
        "200":
          description: Draft version created (not yet published).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      versionId: { type: string, format: uuid }
                      version: { type: integer }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/config/publish:
    post:
      summary: Publish a config draft version (WORK-16)
      description: >-
        WORK-75 — also checked against the session business's super-admin-
        authored policy (`GET /admin/super/businesses/{id}/policy`). A
        bound or hidden-section violation returns 400 with the same
        `details` array shape as any other validation failure.
      security: [ { AdminSessionCookie: [] } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/AdminPublishBody" } } }
      responses:
        "200":
          description: Published; subsequent /check for this business reflects it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      versionId: { type: string, format: uuid }
                      version: { type: integer }
                      publishedAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "404": { description: versionId not found for this business, or console disabled. }
  /admin/config/audit:
    get:
      summary: Publish audit log for the session business (WORK-16)
      security: [ { AdminSessionCookie: [] } ]
      responses:
        "200":
          description: Publish history, newest first.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdminConfigAuditView" } } }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/map/day:
    get:
      summary: Zone-centroid aggregates for one delivery day (WORK-18)
      description: >-
        WORK-18 — the DATA layer behind the admin adjacency map. Returns
        per-postcode-zone anchor, evaluation and defence-refusal counts for
        one London delivery day, scoped to the session's business (the
        business comes from the session cookie; the endpoint accepts no
        business identifier). `day` is the DELIVERY day (London wall-clock)
        — the same key the adjacency engine groups on — not the day the row
        was written.
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: query
          name: day
          required: true
          description: London wall-clock delivery day, in YYYY-MM-DD form.
          schema: { type: string, pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
      responses:
        "200":
          description: Per-zone aggregate for the session business and that day.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdminMapDayView" } } }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/map/points:
    get:
      summary: Individual committed-order delivery points for one day (WORK-22)
      description: >-
        WORK-22 — the session business's own committed-order delivery points
        for one London delivery day, at EXACT coordinates rather than zone
        centroids. `/admin/map/day` answers "how busy was each zone"; this
        answers "where exactly did this business deliver", which zone
        aggregates cannot show.

        The coordinates do not come from the read model — `slot_events` is
        redacted at the persistence boundary and holds no exact point. They
        come from the Redis `anchor-day` index, which is written per
        committed order and is NOT caller-segmented, so a point is returned
        only when its `orderId` also appears in a `slot_events`
        `order_committed` row whose `caller_id` belongs to the session's
        business. A coordinate with no such attribution is dropped, never
        returned. The endpoint accepts no business identifier — the business
        comes from the session cookie.

        Retention is the `anchor-day` index's TTL (absolute, six hours past
        the end of the delivery day), so this endpoint serves LIVE AND
        UPCOMING delivery days only. It has no history: a past day returns
        an honest empty list, not an error. `/admin/map/day` reads Postgres
        and carries no TTL, so the two views of one day diverge once that
        day is over — zone aggregates still resolve, individual points do
        not. That asymmetry is a property of where each coordinate is
        stored, not a fault, and a consumer polling a past day should read
        the empty list as "expired", never as "delivered nothing".
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: query
          name: day
          required: true
          description: London wall-clock delivery day, in YYYY-MM-DD form.
          schema: { type: string, pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
      responses:
        "200":
          description: This business's committed-order points for that day.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdminMapPointsView" } } }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/receipts:
    get:
      summary: Per-refusal decision receipts for one day, business-scoped (WORK-42)
      description: >-
        WORK-42 — the session business's decision receipts: one row per REFUSED
        slot on a POST /check, so an operator can answer "why couldn't this
        customer get 4pm?" after the delivery day. Each receipt carries the
        refusal reason, the postcode zone, and — for the two spatial defences
        (`cluster_reserved`, `adjacency_capacity_full`) — the distance the
        shopper sat from the cluster anchor and the ring in force; both are null
        for non-spatial refusals (`slot_capacity_full`, `no_drivers`, etc.).

        The endpoint accepts no business identifier — the business comes from the
        session cookie, and each receipt row carries `business_id` directly, so
        the read is a single-table tenant filter. Receipts from callers not
        mapped to a business are never returned to any operator.

        Retention is 90 days (a separate prune); a day older than that returns an
        honest empty list, not an error. Newest-first, bounded to the most recent
        page — `truncated` is true when older receipts for the range were not
        returned. Requires FEATURE_POSTGRES_PERSISTENCE for a populated result;
        with the read model off the list is honestly empty.
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: query
          name: day
          required: true
          description: London wall-clock delivery day, in YYYY-MM-DD form.
          schema: { type: string, pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
        - in: query
          name: period
          required: false
          description: >-
            Widen `day` to the calendar month CONTAINING it. Defaults to "day".
            Expressed as a widening of `day` rather than a separate month
            parameter so a caller cannot send a day and a month that disagree.
          schema: { type: string, enum: [day, month], default: day }
      responses:
        "200":
          description: This business's decision receipts for that day/period.
          content: { application/json: { schema: { $ref: "#/components/schemas/AdminReceiptsView" } } }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/zone-boundaries:
    get:
      summary: London postcode-district boundary polygons for the config map picker (WORK-52)
      description: >-
        WORK-52 — the bundled London postcode-district boundary polygons that the
        admin config Zone-caps tab renders as a clickable map. A GeoJSON
        FeatureCollection, one Feature per district, `properties.zone` = the
        postcode-district code (e.g. `SW1A`) and a `Polygon`/`MultiPolygon`
        geometry.

        The payload is global geography (not tenant-scoped like `/admin/map` or
        `/admin/receipts`) — the same file for every business — so it accepts no
        business identifier and carries a 1-hour cache. It is served from the
        admin console purely for session-consistency, not tenant isolation.

        Coverage is the geographic districts among the known zones; non-geographic
        large-user codes (e.g. `CR90`, `SW95`, `SE1P`) and bare parent districts
        Wikipedia splits into sub-districts (e.g. `SW1`, `EC1`, `W1`) legitimately
        have no polygon and are absent — the config UI degrades those to its
        table. Boundaries are approximate and MUST NOT be used as a routing basemap.
        Source: missinglink/uk-postcode-polygons (Wikipedia-derived, CC-BY-SA 3.0);
        see NOTICE.
      security: [ { AdminSessionCookie: [] } ]
      responses:
        "200":
          description: The bundled postcode-district boundary FeatureCollection.
          content:
            application/json:
              schema:
                type: object
                required: [type, features]
                properties:
                  type: { type: string, enum: [FeatureCollection] }
                  features:
                    type: array
                    items:
                      type: object
                      required: [type, properties, geometry]
                      properties:
                        type: { type: string, enum: [Feature] }
                        properties:
                          type: object
                          required: [zone]
                          properties:
                            zone: { type: string, description: Postcode-district code, e.g. SW1A. }
                        geometry:
                          type: object
                          required: [type, coordinates]
                          properties:
                            type: { type: string, enum: [Polygon, MultiPolygon] }
                            coordinates: { description: GeoJSON coordinate array for the district polygon. }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/team:
    get:
      summary: The session business's team roster (WORK-85)
      description: >-
        WORK-85 — who can sign in to this business's console. The endpoint
        accepts no business identifier: the business comes from the session
        cookie, so an admin can only ever read their own team. Every member of
        the business can read the roster; only the owner can change it (see
        POST /admin/team/invite).

        Ordered oldest first, which puts the owner — the business's first admin
        — at the top. `isSuperAdmin` is deliberately NOT exposed: whether a
        member is also a platform operator is not this business's business.
      security: [ { AdminSessionCookie: [] } ]
      responses:
        "200":
          description: This business's team.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      members:
                        type: array
                        items: { $ref: "#/components/schemas/TeamMemberView" }
                      memberLimit: { type: integer, minimum: 1 }
        "401": { description: No valid admin session. }
        "404": { description: Admin console disabled. }
  /admin/team/invite:
    post:
      summary: Invite a colleague to the session business's team (WORK-85, owner only)
      description: >-
        WORK-85 — the owner-facing half of the same invite the super console
        performs: an `admin_users` row on the session's business plus a
        magic-link email from the verified sender. Owner only — a member's
        session is refused with 403 `owner_required`.

        The seat count includes invites whose email failed to send, so a 202
        with `delivered: false` still holds a seat. One business per email: an
        address already provisioned anywhere is refused with 409.
      security: [ { AdminSessionCookie: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteAdminBody" }
      responses:
        "202":
          description: >-
            The admin row was created. `delivered` reports whether the invite
            email itself went out — false means the seat is held but the
            colleague never got a link.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      status: { type: string, enum: [invited] }
                      member:
                        type: object
                        description: >-
                          The row as created — narrower than TeamMemberView
                          (no createdAt / lastLoginAt: the insert does not read
                          them back, and a fresh member has never logged in).
                          Clients wanting the full row re-read GET /admin/team.
                        properties:
                          id: { type: string, format: uuid }
                          email: { type: string, format: email }
                          role: { type: string, enum: [owner, member] }
                      delivered: { type: boolean }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated as a member, not the owner (owner_required). }
        "404": { description: Admin console disabled, or the session's business no longer exists (business_not_found). }
        "409": { description: An admin_users row already exists for this email anywhere (admin_email_conflict). }
        "422":
          description: >-
            The business already holds `member_limit` admins
            (team_cap_reached). A super-admin can raise the limit.
        "429":
          description: >-
            Invite throttle (admin_throttled). This route carries a tighter
            bucket than the rest of the console — burst 5, then about one a
            minute, keyed on the session admin plus ip — because its side
            effect is outbound email to an address the caller supplies.
  /admin/team/members/{adminUserId}:
    delete:
      summary: Remove a member from the session business's team (WORK-85, owner only)
      description: >-
        WORK-85 — removal ends access immediately, not eventually. The
        `admin_users` row is hard-deleted (freeing both the seat and the email
        address for reuse), every unconsumed magic link that member holds is
        killed, and every live session they hold is killed — so a removed
        member's next click lands on the sign-in page rather than working for
        up to `ADMIN_SESSION_TTL_MS`.

        Owner only. The owner cannot be removed (that includes an owner
        removing themselves — ownership transfer is not supported), and a
        member whose account is also a platform super-admin cannot be removed
        by a business owner.

        A member id belonging to a different business is answered exactly like
        an id that does not exist, so this endpoint cannot be used to discover
        whether an id is real.
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: path
          name: adminUserId
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: >-
            The member is gone. The revoked counts are what was actually
            killed; credentials minted before this capability shipped carry no
            index entry and lapse on their own TTL instead.

            `revoked` is false when the row was deleted but the credential
            sweep could not complete (Redis unreachable during the removal).
            The removal itself still stands and is not retryable — the row is
            gone — so this stays a 200 rather than becoming a 500 that would
            report the opposite of what happened. With the row deleted the
            member can no longer request a link and any link they already hold
            is refused at redemption; what may survive is a session they had
            already minted, for at most `ADMIN_SESSION_TTL_MS`. When `revoked`
            is false the counts are what was CONFIRMED killed, not what was
            outstanding, and `warning` carries the operator-facing wording.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      removed: { type: boolean, enum: [true] }
                      revoked:
                        type: boolean
                        description: >-
                          Whether every outstanding session and link was
                          confirmed revoked. False means the row is gone but
                          the sweep did not complete.
                      revokedSessions: { type: integer, minimum: 0 }
                      revokedLinks: { type: integer, minimum: 0 }
                      warning:
                        type: string
                        description: >-
                          Present only when `revoked` is false. Plain-language
                          statement of what is still live.
        "401": { description: No valid admin session. }
        "403": { description: Authenticated as a member, not the owner (owner_required). }
        "404":
          description: >-
            No such member on this business (member_not_found) — also the
            answer for a member of another business. Or the admin console is
            disabled.
        "422":
          description: >-
            The target is the business owner (cannot_remove_owner), or is a
            platform super-admin (cannot_remove_super_admin).
  /admin/super/businesses:
    get:
      summary: List all businesses (WORK-75 super-admin)
      description: >-
        WORK-75 — super-admin-only. Returns every row in `businesses`
        regardless of the session's own tenant. A super-admin session still
        carries its own home `businessId` (there is no null-businessId case
        anywhere in the admin chain), but this endpoint is the one place
        that is deliberately NOT tenant-scoped — every other endpoint under
        `/admin` reads only `res.locals.adminSession`'s own business. See
        the isolation guarantee at docs/production-readiness.md § 11.
      security: [ { AdminSessionCookie: [] } ]
      responses:
        "200":
          description: All businesses, unfiltered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      businesses:
                        type: array
                        items: { $ref: "#/components/schemas/BusinessWithTeam" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated but not a super-admin (super_admin_required). }
        "404": { description: Admin console disabled (FEATURE_ADMIN_CONSOLE off). }
    post:
      summary: Create a business (WORK-75 super-admin)
      description: >-
        WORK-75 — the endpoint equivalent of `scripts/seed-cotta-business.mjs`,
        callable by a super-admin without shell or Postgres access.
      security: [ { AdminSessionCookie: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBusinessBody" }
      responses:
        "201":
          description: Business created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      business: { $ref: "#/components/schemas/Business" }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated but not a super-admin (super_admin_required). }
        "404": { description: Admin console disabled. }
        "409": { description: A business with this slug already exists (business_slug_conflict). }
  /admin/super/businesses/{businessId}/invite:
    post:
      summary: Invite an admin to a business by email (WORK-75 super-admin)
      description: >-
        WORK-75 — the endpoint equivalent of `scripts/seed-admin-user.mjs`
        plus the magic-link send, in one authenticated call. Sends a real
        invite email via the same transport WORK-16's magic-link sign-in
        uses (SMTP, or `ADMIN_MAGIC_LINK_LOG_LINK` in local dev).
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteAdminBody" }
      responses:
        "202":
          description: Invite sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      status: { type: string, enum: [invited] }
                      admin:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          businessId: { type: string, format: uuid }
                          email: { type: string, format: email }
                          role:
                            type: string
                            enum: [owner, member]
                            description: >-
                              WORK-85 — the first admin of a business is its
                              owner; anyone invited afterwards is a member.
                      delivered: { type: boolean }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated but not a super-admin (super_admin_required). }
        "404": { description: Business not found (business_not_found), or admin console disabled. }
        "409": { description: An admin_users row already exists for this email anywhere (admin_email_conflict). }
        "422":
          description: >-
            WORK-85 — the business already holds `member_limit` admins
            (team_cap_reached). The seat count includes invites whose email
            failed to send, so freeing a seat means removing that admin. A
            super-admin can raise the business's member limit.
  /admin/super/businesses/{businessId}/member-limit:
    post:
      summary: Set a business's team seat limit (WORK-85 super-admin)
      description: >-
        WORK-85 — how many admins a business may hold, owner included
        (default 5). Raising it is how a business gets a sixth seat. Lowering
        it below the number of admins the business already has is refused:
        the limit is the ceiling new invites are checked against, and a
        business sitting above its own ceiling makes every later invite
        confusing. Remove an admin first, then lower the limit.
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [memberLimit]
              properties:
                memberLimit: { type: integer, minimum: 1, maximum: 50 }
      responses:
        "200":
          description: The business's new seat limit.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      businessId: { type: string, format: uuid }
                      memberLimit: { type: integer }
                      adminCount: { type: integer }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated but not a super-admin (super_admin_required). }
        "404": { description: Business not found (business_not_found), or admin console disabled. }
        "422": { description: The business already has more admins than the requested limit (limit_below_members). }
  /admin/super/businesses/{businessId}/policy:
    get:
      summary: Read a business's super-admin-authored config policy (WORK-75)
      description: >-
        WORK-75 — the per-business numeric bounds and hidden top-level
        config sections a super-admin has authored for this business. A
        business with no authored policy returns the permissive default
        `{bounds:{}, hiddenSections:[]}` — see `BusinessConfigPolicy`.
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The business's policy (or the permissive default).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      policy: { $ref: "#/components/schemas/BusinessConfigPolicy" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated but not a super-admin (super_admin_required). }
        "404": { description: Business not found, or admin console disabled. }
    post:
      summary: Author a business's config policy (WORK-75)
      description: >-
        WORK-75 — sets per-business min/max numeric bounds on 9 named
        config knobs, and which of the 7 top-level `businessConfigOverride`
        sections that business's own admin can see or edit at all. Enforced
        on that business's own `POST /admin/config/draft` and `POST
        /admin/config/publish` — a violation there returns 400 with a
        `details` array of `{path, message}` (the same `ErrorEnvelope`
        shape every other 400 on this API uses). Full replace, not a
        merge — an omitted bound or hidden section is cleared, not left
        untouched.
      security: [ { AdminSessionCookie: [] } ]
      parameters:
        - in: path
          name: businessId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BusinessConfigPolicy" }
      responses:
        "200":
          description: Policy saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      policy: { $ref: "#/components/schemas/BusinessConfigPolicy" }
        "400": { $ref: "#/components/responses/QueryValidation400" }
        "401": { description: No valid admin session. }
        "403": { description: Authenticated but not a super-admin (super_admin_required). }
        "404": { description: Business not found, or admin console disabled. }
  /journey/feasibility:
    post:
      summary: Two-address journey feasibility for a collect-and-deliver job (WORK-80)
      description: >-
        Dark surface — mounted only when `FEATURE_JOURNEY_FEASIBILITY=true`;
        with the flag off the route returns 404 via the app-level catch-all.


        Answers one question for a job that is **collected at one arbitrary
        address and dropped at another**: given the collection window, can one
        driver complete both legs before the caller's sellable window closes?
        It returns the driven distance and driven time as well as the verdict,
        because a consumer bills on that distance and must be able to defend
        the charge weeks later.


        **The reason values on this endpoint are LOCAL to it and are NOT
        `/check` reason codes.** `/check`'s `reason` enum is a cross-repo
        contract; adding to it on one side only renders a closed slot as
        bookable downstream. This endpoint deliberately carries its own
        two-member vocabulary and adds nothing to that enum.


        **OSRM failure is a 502, never a verdict.** There is no Haversine
        fallback on this path under any configuration, including when
        `OSRM_FALLBACK_TO_HAVERSINE=true`. A fabricated distance is a wrong
        invoice, and an outage must never surface to a customer as "your job
        cannot be delivered".


        **The arithmetic** — the service allowance is applied once per stop,
        so twice in total, from a single configured value:


        `projectedCompletion = collectionWindow.end + serviceAllowance +
        guardDuration + serviceAllowance + safetyBuffer`, and
        `completable = projectedCompletion <= sellableWindowEnd`.


        `guardDuration` is OSRM's free-flow driven duration multiplied by the
        configured traffic posture. OSRM returns free-flow times, so a
        multiplier of 1.0 would make this guard optimistic by construction —
        which is the opposite of its purpose.


        The endpoint is a pure function of its request plus configuration. It
        never reads the clock, so a given request is deterministic.
      operationId: journeyFeasibility
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JourneyFeasibilityRequest" }
      responses:
        "200":
          description: >-
            A verdict was produced. Note that `completable: false` is a
            SUCCESSFUL response — the job simply does not fit the window.
            Failure to produce a verdict is 4xx/5xx, never a false verdict.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JourneyFeasibilitySuccess" }
        "400":
          description: >-
            Request validation failed, or `collectionWindow.end` is not after
            `collectionWindow.start`. `details` carries the Joi path/message
            pairs. Unknown top-level properties are stripped.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401":
          description: Missing or invalid `X-API-Key` header.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404":
          description: Dark surface — `FEATURE_JOURNEY_FEASIBILITY=false` at boot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "413":
          description: Payload too large (>100kb)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: >-
            The authenticated caller's business has **no configured sellable
            window**, so there is nothing to measure against. Deliberately NOT
            a `completable: false` verdict: an unprovisioned business is a
            configuration gap, and reporting it as "not completable" would
            hide a missing setup behind a plausible-looking answer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429":
          description: Per-caller rate limit exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "500":
          description: Internal error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "502":
          description: >-
            OSRM was unreachable, errored, or returned a non-`Ok` code —
            including `NoRoute` for a genuinely unroutable pair. **No verdict
            was produced and no distance is returned.** `details.cause`
            carries the underlying error string. A caller must surface this as
            a temporary failure, never as an undeliverable job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
  /journey/slots-config:
    get:
      summary: Read the caller's sellable-window definition (WORK-80)
      description: >-
        Dark surface — mounted only when `FEATURE_JOURNEY_FEASIBILITY=true`.


        Publishes the authenticated caller's sellable-window definition so a
        consumer can **generate** candidate slot windows from one source of
        truth. Slotster judges slots; it does not generate them — but until
        this endpoint existed a consumer had no way to learn what the business
        considers sellable, and would have had to keep its own copy of the
        window definition. Two systems holding one definition is how they
        drift.


        Resolved through the same per-business configuration path `/check`
        applies when it filters slots, so what this returns and what `/check`
        would accept cannot disagree.
      operationId: journeySlotsConfig
      security:
        - ApiKeyAuth: []
      responses:
        "200":
          description: The caller's sellable-window definition.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JourneySlotsConfigSuccess" }
        "401":
          description: Missing or invalid `X-API-Key` header.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "404":
          description: Dark surface — `FEATURE_JOURNEY_FEASIBILITY=false` at boot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: >-
            The authenticated caller's business has no configured sellable
            window. Same semantics as on `/journey/feasibility` — a
            configuration gap, reported as one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429":
          description: Per-caller rate limit exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "500":
          description: Internal error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: >-
        WORK-5 Slice 1 — static API key required on `/check`,
        `/baseline/check`, and `/slots/events`. Compared against the
        SHA-256 digest set loaded from `API_KEY_HASHES` at boot via
        `crypto.timingSafeEqual` (constant-time; no partial-match timing
        side-channel). `GET /health` and `GET /ready` remain
        unauthenticated. Missing header → 401 `auth_missing_key`;
        non-matching → 401 `auth_invalid_key`.
  headers:
    RetryAfter:
      description: >-
        WORK-5 Slice 2 — integer number of seconds until the caller's
        token bucket will have enough tokens to admit the next request.
        Always >= 1 (HTTP spec: integer seconds). Consumers should apply
        exponential backoff with jitter around this value to avoid
        synchronised retry storms. See docs/consumer-integration.md
        § Rate limiting for a reference implementation.
      schema:
        type: integer
        minimum: 1
    XRequestId:
      description: >-
        Correlation id for this request. If the caller supplied a
        matching `X-Request-Id` request header (regex
        `^[a-zA-Z0-9-]{8,128}$`), it is echoed verbatim; otherwise
        Slotster AI synthesises a v4 UUID via `crypto.randomUUID`
        and returns that. The value is bound to every server log
        line for this request under the `req_id` field, so a caller
        can trace a specific request end-to-end in the server logs.
      required: true
      schema:
        type: string
        pattern: "^[a-zA-Z0-9-]{8,128}$"
    XSlotDefenceTrimmedCount:
      description: >-
        WORK-6 (v0.5) — per-slot driver over-capacity total. Always
        present on `/check` 200 responses when the
        `FEATURE_MAX_DRIVERS_OBSERVABILITY` feature flag is on (default:
        on). Value is the non-negative integer
        `sum(max(0, driversInHub - maxDriversPerSlot))` across the
        request's slots — the number of hub-eligible drivers exceeding
        the caller's declared cap. `0` on every under-capacity request.
        The optimiser does not trim in v0.5; this is a wire-visible
        signal for the caller's dispatch / planning layer. Absent when
        the feature flag is off — treat absence as "unknown", not "0".
      schema:
        type: integer
        minimum: 0
    AdminSessionCookie:
      type: apiKey
      in: cookie
      name: slotster_admin_session
      description: >-
        WORK-16 Slice 3 — admin-console session cookie (HttpOnly, SameSite=Lax,
        Path=/admin). Issued by POST /admin/auth/verify; validated server-side
        against Redis. A separate identity from the X-API-Key caller auth.
  # SLOT-1 Slice 11 — shared parameters + responses for the batcave
  # read API's four endpoints.
  parameters:
    ReadSince:
      in: query
      name: since
      required: false
      description: >-
        ISO-8601 lower bound (inclusive) on the row's sort column
        (created_at / decided_at / transitioned_at, depending on
        endpoint). Rejected as 400 if not ISO-8601.
      schema:
        type: string
        format: date-time
    ReadUntil:
      in: query
      name: until
      required: false
      description: >-
        ISO-8601 upper bound (exclusive) on the row's sort column. If
        `cursor` is also supplied, `cursor` wins (server-issued
        pagination token takes precedence).
      schema:
        type: string
        format: date-time
    ReadCallerId:
      in: query
      name: callerId
      required: false
      description: >-
        Ignored if it differs from the authenticated caller (from
        X-API-Key). Cross-consumer reads are forbidden; the server
        substitutes the authenticated caller_id and emits a
        `read_caller_scope_narrowed` warning log. Field exists so
        consumer code that supplies it as a no-op does not 400.
      schema:
        type: string
        maxLength: 64
    ReadLimit:
      in: query
      name: limit
      required: false
      description: Rows per page. Default 100. Server-side cap 1000.
      schema:
        type: integer
        minimum: 1
        maximum: 1000
        default: 100
    ReadCursor:
      in: query
      name: cursor
      required: false
      description: >-
        Pagination cursor — ISO-8601 timestamp echoed from the previous
        response's `data.nextCursor`. On the request, the server treats
        it as an exclusive upper bound (`< cursor`) on the row's sort
        column, then returns the next page in DESC order.
      schema:
        type: string
        format: date-time
  responses:
    Auth401:
      description: Missing or invalid X-API-Key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    QueryValidation400:
      description: Query-string validation failed (limit out of range, non-ISO date, unknown field).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    RateLimit429:
      description: Caller has exhausted their per-caller token bucket.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Postgres502:
      description: Postgres query failed (feature flag on but plane unreachable).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
  schemas:
    BusinessConfigOverridePayload:
      type: object
      description: Per-business config override — mirrors businessConfigOverride.ts; all keys optional.
      properties:
        thresholds:
          type: object
          properties:
            inRangeThresholdKm: { type: number }
            detourThresholdMinutes: { type: number }
            osrmFallbackToHaversine: { type: boolean }
            osrmMaxCallsPerRequest: { type: integer }
        driver:
          type: object
          properties:
            driverCapacityDefault: { type: integer }
        features:
          type: object
          properties:
            maxDriversObservability: { type: boolean }
            prebookClustering: { type: boolean }
            volumeDefence: { type: boolean }
            adjacencyDefence: { type: boolean }
        volumeDefence:
          type: object
          properties:
            defaultCap:
              type: integer
              minimum: 0
              description: >-
                WORK-53 — cap for every zone not named in `caps`. A positive value
                governs uncapped zones at that hourly cap; **0 deactivates** them
                (every slot in an uncapped zone is refused `slot_capacity_full`).
            caps:
              type: object
              additionalProperties: { type: integer }
            groups:
              type: object
              description: >-
                WORK-55 — operator-defined VISUAL organisation of postcode
                districts into named, coloured groups for the admin map. Purely
                presentational — never read by the /check enforcement path; it
                round-trips through the published config so the map can restore
                it. One district → one group. (Supersedes WORK-53's fixed-6
                {assignment:1..6, names} shape; the admin frontend migrates old
                rows on read.)
              properties:
                list:
                  type: array
                  maxItems: 50
                  description: >-
                    operator-created groups. Unlike WORK-53's fixed palette, the
                    colour + name persist here, and a group survives with no
                    assigned districts.
                  items:
                    type: object
                    required: [id, colour]
                    properties:
                      id:
                        type: string
                        pattern: '^g[0-9]+$'
                        maxLength: 12
                        description: stable group id, e.g. "g1".
                      name:
                        type: string
                        maxLength: 40
                        description: operator-supplied name (falls back to "Group N" when blank).
                      colour:
                        type: string
                        pattern: '^#[0-9a-fA-F]{6}$'
                        description: operator-chosen hex colour, "#rrggbb".
                      cap:
                        type: integer
                        minimum: 0
                        description: >-
                          WORK-55 — optional per-group cap. Enforces PER-DISTRICT
                          (each member district resolves to this cap) and WINS over
                          a district's own cap (group > own > default). 0 deactivates
                          the group's districts. Absent = visual-only group.
                assignment:
                  type: object
                  description: postcode district → group id (a list[].id).
                  additionalProperties: { type: string, pattern: '^g[0-9]+$', maxLength: 12 }
        adjacency:
          type: object
          description: >-
            WORK-18 per-business adjacency-defence knobs. The ring radius is
            deliberately NOT here — it stays a global platform constant
            (CLUSTER_RADIUS_KM), shared with cluster reservation.
          properties:
            baseSlotCapacity: { type: integer }
            minSlotCapacity: { type: integer }
            driverCount: { type: integer }
            driverCountByDate:
              type: object
              description: Per-London-day driver-count exceptions, keyed YYYY-MM-DD.
              additionalProperties: { type: integer }
        slots:
          type: object
          description: >-
            WORK-46 per-business delivery-slot timing. A slot whose London
            start-hour is outside deliveryWindow or in disabledHours is
            unavailable (reasons outside_delivery_window / slot_disabled).
          properties:
            deliveryWindow:
              type: object
              required: [startHour, endHour]
              properties:
                startHour: { type: integer, minimum: 0, maximum: 23 }
                endHour: { type: integer, minimum: 1, maximum: 24 }
            disabledHours:
              type: array
              items: { type: integer, minimum: 0, maximum: 23 }
              uniqueItems: true
    AdminPublishBody:
      type: object
      required: [versionId]
      properties:
        versionId: { type: string, format: uuid }
    AdminConfigView:
      type: object
      properties:
        defaults: { type: object }
        published:
          type: object
          nullable: true
          properties:
            versionId: { type: string, format: uuid }
            version: { type: integer }
            payload: { type: object }
            publishedAt: { type: string, format: date-time }
        draft:
          type: object
          nullable: true
          properties:
            versionId: { type: string, format: uuid }
            version: { type: integer }
            payload: { type: object }
    AdminConfigAuditView:
      type: object
      properties:
        entries:
          type: array
          items:
            type: object
            properties:
              actorEmail: { type: string }
              version: { type: integer }
              previousVersion: { type: integer, nullable: true }
              publishedAt: { type: string, format: date-time }
              before: { type: object, nullable: true }
              after: { type: object }
    AdminMapDayView:
      type: object
      description: >-
        WORK-18 — per-zone aggregate for one London delivery day.

        PRIVACY POSTURE (this contract is where it is recorded): the payload
        carries POSTCODE-ZONE CENTROIDS ONLY and never a customer
        coordinate. Both source columns are redacted at the persistence
        boundary (`src/utils/redactForPersistence.ts`), so no exact delivery
        point survives in the read model to be returned, and this endpoint
        adds none. The only coordinate on the wire is `zones[].centroid` —
        the bundled district centroid for the zone itself, identical for
        every order in that zone.
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            day:
              type: string
              pattern: "^\\d{4}-\\d{2}-\\d{2}$"
              description: The requested London delivery day, echoed back.
            scanWindow:
              type: object
              description: >-
                The `created_at` window actually scanned, disclosed rather
                than applied silently. The lower bound is a fixed lookback
                from the requested day; a delivery committed further ahead
                than that is not counted, and the operator can see the bound
                that produced the number.
              properties:
                fromIso: { type: string, format: date-time }
                toIso: { type: string, format: date-time }
            zones:
              type: array
              description: Zones with at least one anchor or evaluation on that day, sorted by zone.
              items:
                type: object
                properties:
                  zone:
                    type: string
                    description: Outward postcode code, e.g. SE1.
                  centroid:
                    type: object
                    nullable: true
                    description: >-
                      Bundled centroid of the postcode district, or null when
                      the zone has no centroid in the bundle (London
                      "specials" such as SW1W). Null does NOT suppress the
                      row — the counts are still reported.
                    properties:
                      lat: { type: number }
                      lng: { type: number }
                  anchors:
                    type: integer
                    description: >-
                      Committed orders (`order_committed` events) whose
                      delivery day is this day.
                  evaluations:
                    type: integer
                    description: Slot requests evaluated for this zone on this day.
                  defended: { $ref: "#/components/schemas/AdminMapDefendedCounts" }
            totals:
              type: object
              description: Sum across every returned zone.
              properties:
                anchors: { type: integer }
                evaluations: { type: integer }
                defended: { $ref: "#/components/schemas/AdminMapDefendedCounts" }
            unresolvedAnchors:
              type: integer
              description: >-
                Anchors whose zone could not be resolved — the persisted
                delivery address reduced to the redaction marker rather than
                to an outward code. Reported separately rather than dropped
                silently, so a zone total that looks low can be explained.
    AdminReceiptsView:
      type: object
      description: >-
        WORK-42 — per-refusal decision receipts for one London delivery day,
        scoped to the session's business. Redaction-safe by construction: the
        underlying slot_receipts table has no coordinate or full-postcode
        column, so this payload carries only an outward postcode zone plus
        scalars (distance/ring in km). distanceKm/ringKm are present only for the
        two spatial defences; null otherwise.
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            day:
              type: string
              pattern: "^\\d{4}-\\d{2}-\\d{2}$"
              description: The requested London delivery day, echoed back.
            period: { type: string, enum: [day, month] }
            count:
              type: integer
              description: Number of receipts returned (after the truncation cap).
            truncated:
              type: boolean
              description: >-
                True when older receipts for the range were not returned
                (bounded newest-first page).
            receipts:
              type: array
              items:
                type: object
                properties:
                  slotId: { type: string }
                  vendorId: { type: string }
                  postcodeZone:
                    type: string
                    nullable: true
                    description: Outward postcode zone only (e.g. "SW1W") — never a full postcode.
                  reason:
                    type: string
                    description: The refusal reason (a member of the SlotDecision reason enum).
                  distanceKm:
                    type: number
                    nullable: true
                    description: >-
                      Distance from the per-slot median anchor; present only for
                      cluster_reserved / adjacency_capacity_full.
                  ringKm:
                    type: number
                    nullable: true
                    description: The ring in force; present only for the two spatial defences.
                  londonDay: { type: string, pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
                  createdAt: { type: string, format: date-time }
                  reqId:
                    type: string
                    description: The /check request id that produced the refusal, for log correlation.
    AdminMapPointsView:
      type: object
      description: >-
        WORK-22 — individual committed-order delivery points for one London
        delivery day, scoped to the session's business.

        PRIVACY POSTURE (this contract is where it is recorded): unlike
        `AdminMapDayView`, this payload DOES carry exact customer delivery
        coordinates. That is the point of the endpoint, and it is why the
        tenant filter is the load-bearing control: a point is emitted only
        when the `order_committed` row bearing its `orderId` was written by a
        caller belonging to the requesting session's business. `orderId` is
        deliberately NOT on the wire — the map needs a position, not an
        order identity — so the payload carries the minimum a map can be
        drawn from.
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            day:
              type: string
              pattern: "^\\d{4}-\\d{2}-\\d{2}$"
              description: The requested London delivery day, echoed back.
            points:
              type: array
              description: >-
                One entry per committed order attributed to this business on
                that day, sorted by `windowStart` then `slotId` so repeated
                reads of an unchanged day return an identical payload.
              items:
                type: object
                properties:
                  lat: { type: number }
                  lng: { type: number }
                  slotId:
                    type: string
                    description: The slot the order committed against.
                  windowStart:
                    type: string
                    format: date-time
                    description: Start of that slot's delivery window.
            count:
              type: integer
              description: Length of `points`, for a caller that only needs the total.
            reservationRadiusKm:
              type: number
              description: >-
                WORK-23 — the live `clusterReservation.radiusKm` (env
                `CLUSTER_RADIUS_KM`, default 1.609), in kilometres. This is the
                SAME global radius the adjacency and cluster-reservation
                engines charge against, read from config at request time — so a
                consumer drawing a ring around a point draws the real
                reservation ring rather than a hardcoded mile that silently
                lies the day the env changes. Global, not per-business:
                `AdjacencyDefenceConfig` deliberately carries no radius of its
                own.
    AdminMapDefendedCounts:
      type: object
      description: Refusals attributable to a defence, by reason.
      properties:
        adjacency_capacity_full: { type: integer }
        cluster_reserved: { type: integer }
        slot_capacity_full: { type: integer }
    AdminRequestLinkBody:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email, maxLength: 254 }
    AdminVerifyBody:
      type: object
      required: [token]
      properties:
        token: { type: string, pattern: "^[A-Za-z0-9_-]{40,64}$" }
    AdminSessionView:
      type: object
      properties:
        email: { type: string, format: email }
        business:
          type: object
          properties:
            id: { type: string, format: uuid }
            slug: { type: string }
            name: { type: string }
        isSuperAdmin: { type: boolean }
        role:
          type: string
          enum: [owner, member]
          description: >-
            WORK-85 — the signed-in admin's role in their business. The owner
            manages the team; a member uses the console. Sessions minted before
            WORK-85 report `owner`.
    Business:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
    TeamMemberView:
      description: >-
        WORK-85 — one member of a business's team as its own admins see them.
        Deliberately narrower than the internal AdminUser: no isSuperAdmin,
        no business id (the caller's session already fixes the business).
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        role: { type: string, enum: [owner, member] }
        createdAt: { type: string, format: date-time }
        lastLoginAt:
          type: string
          format: date-time
          nullable: true
          description: Null until the member has followed a magic link at least once.
    BusinessWithTeam:
      description: >-
        WORK-85 — a business plus its team occupancy, so the super console can
        show `adminCount / memberLimit` and offer Invite for a business that
        has no admin yet (slot-defence#191: that control used to exist only in
        the page session that created the business).
      allOf:
        - $ref: "#/components/schemas/Business"
        - type: object
          properties:
            adminCount: { type: integer, minimum: 0 }
            memberLimit: { type: integer, minimum: 1 }
    CreateBusinessBody:
      type: object
      required: [slug, name]
      properties:
        slug:
          type: string
          pattern: "^[a-z0-9]+(-[a-z0-9]+)*$"
          minLength: 2
          maxLength: 64
        name: { type: string, minLength: 1, maxLength: 200 }
    InviteAdminBody:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email, maxLength: 254 }
    PolicyBound:
      type: object
      description: Both optional; when both are present, min must be <= max.
      properties:
        min: { type: number }
        max: { type: number }
    BusinessConfigPolicy:
      type: object
      description: >-
        WORK-75 — a super-admin-authored per-business policy. Both
        properties are optional; an absent policy behaves as
        `{bounds:{}, hiddenSections:[]}` (fully permissive).
      properties:
        bounds:
          type: object
          description: >-
            Min/max numeric bounds per knob. Each bound mirrors the exact
            numeric constraint `businessConfigOverride.ts` declares for
            that field.
          properties:
            inRangeThresholdKm: { $ref: "#/components/schemas/PolicyBound" }
            clusterRadiusKm: { $ref: "#/components/schemas/PolicyBound" }
            detourThresholdMinutes: { $ref: "#/components/schemas/PolicyBound" }
            osrmMaxCallsPerRequest: { $ref: "#/components/schemas/PolicyBound" }
            driverCapacityDefault: { $ref: "#/components/schemas/PolicyBound" }
            volumeDefenceDefaultCap: { $ref: "#/components/schemas/PolicyBound" }
            adjacencyBaseSlotCapacity: { $ref: "#/components/schemas/PolicyBound" }
            adjacencyMinSlotCapacity: { $ref: "#/components/schemas/PolicyBound" }
            adjacencyDriverCount: { $ref: "#/components/schemas/PolicyBound" }
        hiddenSections:
          type: array
          description: >-
            Top-level `businessConfigOverride` sections this business's own
            admin cannot see or edit at all.
          items:
            type: string
            enum: [thresholds, driver, features, volumeDefence, adjacency, slots, coldStartAnchor]
    HealthResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]
    ReadyResponse:
      oneOf:
        - type: object
          required: [status, osrm, redis]
          properties:
            status:
              type: string
              enum: [ready]
            osrm:
              $ref: "#/components/schemas/DependencyReachable"
            redis:
              $ref: "#/components/schemas/DependencyReachable"
        - type: object
          required: [status, osrm, redis]
          properties:
            status:
              type: string
              enum: [not_ready]
            osrm:
              oneOf:
                - $ref: "#/components/schemas/DependencyReachable"
                - $ref: "#/components/schemas/DependencyUnreachable"
            redis:
              oneOf:
                - $ref: "#/components/schemas/DependencyReachable"
                - $ref: "#/components/schemas/DependencyUnreachable"
    DependencyReachable:
      type: object
      required: [reachable, latencyMs, checkedAt]
      properties:
        reachable:
          type: boolean
          enum: [true]
        latencyMs:
          type: number
          minimum: 0
        checkedAt:
          type: string
          format: date-time
    DependencyUnreachable:
      type: object
      required: [reachable, error, checkedAt]
      properties:
        reachable:
          type: boolean
          enum: [false]
        error:
          type: string
        checkedAt:
          type: string
          format: date-time
    Coordinate:
      type: object
      required: [lat, lng]
      properties:
        lat:
          type: number
          minimum: -90
          maximum: 90
        lng:
          type: number
          minimum: -180
          maximum: 180
    SlotRequest:
      type: object
      required: [slotId, vendorId, windowStart, windowEnd, customerPostcodeZone, hubId]
      properties:
        slotId: { type: string, minLength: 1 }
        vendorId: { type: string, minLength: 1 }
        windowStart: { type: string, format: date-time }
        windowEnd: { type: string, format: date-time }
        orderCount:
          type: integer
          minimum: 1
          default: 1
        specialConditions:
          type: array
          items: { type: string, minLength: 1 }
          default: []
        customerPostcodeZone:
          type: string
          pattern: '^[A-Z]{1,2}[0-9]{1,2}[A-Z]?$'
          description: >-
            London postcode district only (e.g. "SW11", "E14") — not a full
            postcode. Cotta owns customer→zone resolution; Slotster AI
            never receives a full address or coordinates below district
            resolution. Zones outside the bundled 52-district map yield
            `reason: no_route_covers_customer_zone` on the decision.
        hubId:
          type: string
          minLength: 1
          description: >-
            Delivery Hub identifier (e.g. "hub-bermondsey"). Cotta pre-resolves
            customer→Hub and sends this. Slotster AI filters candidate drivers
            strictly by hubId — no cross-hub fallback.
        maxDriversPerSlot:
          type: integer
          minimum: 1
          maximum: 2000
          default: 4
          description: >-
            WORK-6 (v0.5) — per-slot cap on the number of drivers that
            should be treated as competing for this slot. Observability-only
            in v0.5: Slotster AI computes `max(0, driversInHub - cap)` per
            slot, sums across the request, and returns the total on the
            `X-Slot-Defence-Trimmed-Count` response header. The optimiser
            itself does not trim. Default 4 mirrors Cotta's current
            dispatch-side per-slot limit; callers may raise or lower it
            per slot as their capacity picture demands.
        slotCapacityRemaining:
          type: integer
          minimum: 0
          description: >-
            WORK-9 (v0.8.0) — per-slot capacity remaining. Optional at the
            schema layer; **required** per SlotRequest when the top-level
            `slotCapacityMode="permissive"`. In permissive mode, `>0` →
            available; `<=0` → unavailable with reason `slot_capacity_full`.
            Ignored in strict mode. Cotta-shape marketplaces compute this
            as `maxOrdersInSlot - assignedOrdersInSlot`.
    Driver:
      type: object
      required:
        [driverId, position, vehicleRangeKm, capacityRemaining, hubId]
      properties:
        driverId: { type: string, minLength: 1 }
        position: { $ref: "#/components/schemas/Coordinate" }
        vehicleRangeKm:
          type: number
          exclusiveMinimum: 0
        vehicleCapabilities:
          type: array
          items: { type: string, minLength: 1 }
          default: []
        capacityRemaining:
          type: integer
          minimum: 0
        hubId:
          type: string
          minLength: 1
          description: >-
            Delivery Hub this Vok bike is dispatched from. Slotster AI
            filters candidates strictly by hubId matching the request's hubId.
    Hub:
      type: object
      required: [hubId, position, name]
      properties:
        hubId: { type: string, minLength: 1 }
        position: { $ref: "#/components/schemas/Coordinate" }
        name: { type: string, minLength: 1 }
    ActiveRouteStop:
      type: object
      required: [stopId, position, eta]
      properties:
        stopId: { type: string, minLength: 1 }
        position: { $ref: "#/components/schemas/Coordinate" }
        eta: { type: string, format: date-time }
    ActiveRoute:
      type: object
      required: [driverId, stops]
      properties:
        driverId: { type: string, minLength: 1 }
        stops:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/ActiveRouteStop" }
    VendorCatchment:
      type: object
      required: [vendorId, position, catchmentRadiusKm]
      properties:
        vendorId: { type: string, minLength: 1 }
        position: { $ref: "#/components/schemas/Coordinate" }
        catchmentRadiusKm:
          type: number
          exclusiveMinimum: 0
    CheckRequest:
      type: object
      required: [slotRequests, drivers, vendorCatchments]
      properties:
        slotRequests:
          type: array
          items: { $ref: "#/components/schemas/SlotRequest" }
        drivers:
          type: array
          items: { $ref: "#/components/schemas/Driver" }
        activeRoutes:
          type: array
          items: { $ref: "#/components/schemas/ActiveRoute" }
          default: []
        vendorCatchments:
          type: array
          items: { $ref: "#/components/schemas/VendorCatchment" }
        nowIso:
          type: string
          format: date-time
          description: >-
            Optional server-time override for slot-window comparisons.
            Scenario suites and replayable tests pass this to make the window
            gate deterministic. Production callers omit it and the server
            uses its own clock.
        slotCapacityMode:
          type: string
          enum: [strict, permissive]
          default: strict
          description: >-
            WORK-9 (v0.8.0) — accept/reject test switch. `strict` (default;
            every WORK-7 caller unchanged) tests on driver-readiness per
            the WORK-4 optimiser. `permissive` switches the test to
            slot-level capacity — SlotRequest.slotCapacityRemaining>0 →
            available; <=0 → slot_capacity_full; slot-window-missed still
            rejects. Advisory response fields (driverReadyNow,
            expectedDispatchType) surface the strong-signal case for
            callers that want it without gating on it. Permissive mode
            still runs the full OSRM/anchor optimisation so the batching
            insight (anchorDriverId, detourMinutes) is preserved intact.
        dispatchProvider:
          type: string
          pattern: "^[a-z][a-z0-9-]*$"
          description: >-
            WORK-10 (v0.9.0 Slice 1) — optional dispatch-provider key,
            per-order override of the marketplace default. Lowercase
            kebab shape. When absent, the controller resolves the
            marketplace default (looked up by
            `res.locals.callerId`). Registry lookup returns
            `dispatch_provider_unknown` (400) if the key is not in the
            global `DISPATCH_PROVIDER_KEYS` registry, or
            `dispatch_provider_not_entitled` (422) if the key exists
            globally but is not in the caller's marketplace allowlist,
            or if the caller's caller-id is not registered under any
            marketplace at all. Silently ignored when
            `FEATURE_DISPATCH_PROVIDERS=false` (BC guarantee). Slice 1
            resolves-only; the resolved provider is threaded into the
            outbound `/slots/events activate` POST in Slice 2.
        pendingOrderDistribution:
          type: object
          description: >-
            WORK-15 (v0.12.0 Slice 1) — optional pre-book clustering input.
            Nested map `{[sector]: {[hour]: count}}` reporting the caller's
            already-booked pre-booked orders per postcode sector per
            clock-hour. Sector keys are the same postcode district shape
            already used by `customerPostcodeZone` (e.g. `SW11`, `SE1`).
            Hour keys are ISO clock-hour strings aligned on the hour
            (e.g. `2026-08-04T09:00`). Cell values are non-negative
            integers. Aggregate counts only — no orders, no addresses, no
            customer identifiers. When present with
            `FEATURE_PREBOOK_CLUSTERING=true`, FLAGS clustered slots
            (zone+hour cell count ≥ N; per-consumer N config, default 1)
            by setting `cluster: true` on them. Flagging stops above M
            (default 5). Slotster does NOT reorder or re-score
            `decisions[]` — the array is always returned in `slotRequests`
            input order, and ranking is the consumer's decision (see the
            /check description). Absent, malformed, or clustering-throws
            all degrade to today's behaviour (no flags). Requires
            `pendingOrderDistributionTimezone` sibling; the two are a pair.
          additionalProperties:
            type: object
            description: Zone → hour → count map.
            additionalProperties:
              type: integer
              minimum: 0
          example:
            SW11:
              "2026-08-04T09:00": 3
              "2026-08-04T10:00": 1
            SW12:
              "2026-08-04T09:00": 2
        pendingOrderDistributionTimezone:
          type: string
          enum: ["Europe/London"]
          description: >-
            WORK-15 (v0.12.0 Slice 1) — required sibling of
            `pendingOrderDistribution`. Names the timezone the hour keys
            were derived in. Currently the only accepted value is
            `"Europe/London"` — the caller derives the cell key once at
            aggregation from UTC-stored timestamps so Slotster stays a
            pure stateless function of its input with no DST table. Any
            other value returns 400. Only meaningful when
            `pendingOrderDistribution` is present; ignored otherwise.
        vendorOperatingDays:
          type: object
          additionalProperties:
            type: array
            uniqueItems: true
            items:
              type: string
              enum: [MON, TUE, WED, THU, FRI, SAT, SUN]
          description: >-
            SLOT-1 (v0.14.0 Slice 14) — vendor operating-days handshake.
            Dictionary keyed by vendorId → allowlist of 3-letter
            uppercase day codes for the days that vendor operates on.
            Semantics — MISSING vendor from map = LENIENT no filter for
            that vendor; EMPTY array = vendor operates NO days (all
            their slotRequests filtered); PRESENT with a subset =
            slotRequests whose `windowStart` falls on a non-listed day
            are short-circuited BEFORE OSRM / anchor evaluation with
            `decision: "unavailable", reason: "vendor_not_operating"`.
            Day-of-week is derived from `windowStart` in Europe/London
            (fixed — matches WORK-15's clustering timezone constraint).
            Field is fully optional — absence = no filter, backward
            compatible with pre-Slice-14 callers.
          example:
            "vendor-borough": [MON, TUE, WED, THU, FRI]
            "vendor-sunday-farm": [SAT, SUN]
        customerLocation:
          type: object
          required: [lat, lng]
          properties:
            lat: { type: number, minimum: -90, maximum: 90 }
            lng: { type: number, minimum: -180, maximum: 180 }
          description: >-
            SLOT-1 (v0.15.0 Slice 16) — optional real customer
            coordinates. When present, the optimiser uses these
            directly and skips the internal postcode-centroid lookup
            (which requires Slotster to maintain a hardcoded UK-geography
            JSON that duplicates the caller's authoritative
            service-area config). Absence = today's centroid-lookup
            behaviour for backward compatibility. When present,
            unknown `customerPostcodeZone` no longer rejects the slot
            (the coord is authoritative). Recommended for every caller
            that has the customer's actual lat/lng; the zone stays
            as a routing/logging hint. Closes slot-defence#55.
          example:
            lat: 51.494
            lng: -0.149
        customerHubspotId:
          type: string
          maxLength: 64
          description: >-
            WORK-62 — optional opaque HubSpot id of the customer making this
            check. Used ONLY to list affected users on the volume-defence Slack
            card when their check trips the cap; card-only, not persisted beyond
            the trip TTL. Absent = today's behaviour (no user listed).
          example: "0123456789"
        holdOrderId:
          type: string
          maxLength: 128
          description: >-
            WORK-62 — the caller's own active reserve/hold order id (the same
            value sent to /reserve). When present AND
            FEATURE_CHECK_EXCLUDES_OWN_HOLD is enabled, the caller's own hold
            stops counting against the caller who placed it, in the hold's
            own (zone, hour) cell, in two places:

            1. At cap — the held slot is exempt from the volume-defence cap
            (returned available instead of slot_capacity_full), carrying
            `slotCapacityRemaining: 1`, the caller's own unit.

            2. Below cap (WORK-93) — `slotCapacityRemaining` adds the
            caller's own unit back, so the number they read is the headroom
            they actually have rather than one lowered by their own hold.

            Both are caller-relative display/decision relief only: the stored
            zone-hour count is never changed by /check, and every other
            caller still sees the hold counted. Absent / flag off = today's
            behaviour.
          example: "64f0-2026-08-28T14:00-2026-08-28T15:00"
    Candidate:
      type: object
      required: [driverId, distanceKm, capacityRemaining]
      properties:
        driverId: { type: string }
        distanceKm: { type: number }
        capacityRemaining: { type: integer }
    SlotDecision:
      type: object
      required:
        [slotId, vendorId, decision, reason, detourMinutes, evaluationMode, candidates, activeAnchorMatch, driverReadyNow]
      properties:
        slotId: { type: string }
        vendorId: { type: string }
        decision:
          type: string
          enum: [available, unavailable]
        reason:
          type: string
          description: >-
            Stable machine-readable enum. WORK-4 Slice 2 adds
            no_route_covers_customer_zone (no hub-owned driver has a planned
            route covering the customer's zone — batching narrative distinct
            from no_driver_in_range which means "no hub driver within reach").
            WORK-5 Slice 4 additionally uses no_route_covers_customer_zone
            when a request exceeds the per-request OSRM call budget
            (OSRM_MAX_CALLS_PER_REQUEST, default 500). Budget exhaustion is
            deliberately indistinguishable from "no route" on the wire:
            consumers should size requests appropriately rather than build
            behaviour on server load. Server-side operators see the
            distinction via the pino event osrm_budget_exhausted.
          enum:
            - anchor_in_range_with_capacity
            - route_insertion_within_threshold
            - no_drivers
            - no_driver_in_range
            - no_route_covers_customer_zone
            - driver_at_capacity
            - special_conditions_unmet
            - driver_vehicle_range_insufficient
            - route_insertion_exceeds_threshold
            - slot_window_missed
            - routing_unavailable
            - vendor_catchment_missing
            - slot_capacity_full
            # SLOT-1 (v0.20.0 Slice 27c-ii) — the slot is anchored to a
            # cluster this customer is not in. A committed order already
            # holds this slot, and this delivery point is further than
            # CLUSTER_RADIUS_KM from it, so the remaining capacity is being
            # held for drops near the anchor.
            #
            # Deliberately DISTINCT from slot_capacity_full: that means
            # nobody can have the slot; this means the slot is fulfillable
            # for someone, just not for this customer. A consumer that
            # collapses the two will tell the shopper the wrong thing.
            #
            # Only ever emitted when FEATURE_CLUSTER_RESERVATION is on AND
            # both the anchor and the request carry a coordinate. Missing
            # either fails OPEN — the slot stays available.
            - cluster_reserved
            # WORK-17 (v0.21.0) — adjacent-slot defence. The slot's capacity
            # has been reduced to zero by the travel a driver must make from
            # the previous slot's cluster, so this slot cannot take another
            # drop even though the zone-hour itself is not full.
            #
            # Deliberately DISTINCT from slot_capacity_full and from
            # cluster_reserved. slot_capacity_full means the zone-hour is out
            # of capacity for everyone; cluster_reserved means the slot is
            # held for a nearby run and a different delivery address would
            # help. This one means the DAY's shape is the constraint — no
            # change of address by this customer alters it, because the limit
            # comes from where OTHER slots on the same day have anchored.
            #
            # Transient, like every reason except vendor_not_operating: the
            # anchor medians move as orders land, so a slot refused this way
            # can become available again without anything being retried.
            #
            # Only ever emitted when FEATURE_ADJACENCY_DEFENCE is on AND both
            # the previous and current slot carry an anchor coordinate.
            # Missing either fails OPEN — capacity is not reduced.
            #
            # UNREACHABLE AT DEFAULT CONFIGURATION. Capacity is clamped at
            # ADJACENCY_MIN_SLOT_CAPACITY, which defaults to 1, so it never
            # reaches zero: adjacency in its default posture REDUCES a
            # slot's capacity but never withholds the slot. Emitting this
            # reason requires an operator to have lowered that floor to 0.
            # Integrators should handle the code for completeness and not
            # expect it in traffic.
            - adjacency_capacity_full
            # SLOT-1 (v0.14.0 Slice 14) — vendor operating-days
            # handshake. Fires when the slot's windowStart (Europe/London
            # day) is not in the vendor's `vendorOperatingDays` allowlist.
            # Earliest per-slot filter — precedes anchor/route/capacity.
            - vendor_not_operating
            # SLOT-1 (v0.16.0 Slice 22) — the customer's postcode zone is
            # absent from the bundled centroid map AND the caller sent no
            # `customerLocation`, so there is no coordinate to measure a
            # delivery leg against. Previously reported as
            # `no_route_covers_customer_zone`, which was misleading: that
            # reason claims no route reaches the zone, when in fact the
            # zone is simply unrecognised. Send `customerLocation` to
            # avoid this path entirely.
            - customer_zone_unknown
            # WORK-46 — per-business delivery window / disabled hours.
            - outside_delivery_window
            - slot_disabled
        anchorDriverId:
          type: string
          description: Present when decision is "available".
        detourMinutes:
          type: number
          description: >-
            Route-insertion cost over the driver's current route, in minutes.
            0 when the anchor driver is idle (no active route) or when the
            fallback path is used. Populated even on "unavailable" outcomes
            where a candidate was scored but exceeded the threshold — so the
            caller can see how close the near-miss was.
        evaluationMode:
          type: string
          enum: [osrm, haversine-fallback]
          description: >-
            "osrm" when routing was live and the detour was scored. "haversine-fallback"
            when OSRM was unreachable and the decision reflects Slice 1
            in-range + capacity logic only. The caller can use this to
            decide whether to trust a batching decision or treat it as a
            degraded answer.
        candidates:
          type: array
          items: { $ref: "#/components/schemas/Candidate" }
        activeAnchorMatch:
          type: boolean
          description: >-
            true iff the driver Slotster AI picked as this slot's anchor
            already has an active anchor entry in Redis (activated via
            POST /slots/events) whose slot window overlaps this slot's
            window AND whose validUntil is still in the future at the
            request-time clock. All four clauses must hold; any
            short-circuit → false. false trivially on unavailable
            decisions (no anchor picked), on haversine-fallback
            decisions (degraded path, no lookup), and on baseline
            (baseline never consults Redis). Orthogonal to `reason` —
            reason answers "why this decision", activeAnchorMatch
            answers "is this slot batchable with another order."
        driverReadyNow:
          type: boolean
          description: >-
            WORK-9 (v0.8.0) — advisory field carried on every decision
            (both modes). true iff the strict-mode logic would have
            accepted this slot on driver grounds (anchor found, capacity
            OK, range OK, specials met, detour within threshold). In
            strict mode this is tautologically equal to
            `decision === "available"`. In permissive mode it can be true
            even when decision is unavailable/slot_capacity_full (driver
            was ready but slot is full), and false even when decision is
            available (permissive accepts on capacity alone; dispatch
            will find a driver later). Callers who want the strong-signal
            case ("driver confirmed now") key off this field.
        slotCapacityRemaining:
          type: integer
          minimum: 0
          description: >-
            How many more deliveries this decision can still take, as seen by
            the caller asking — so a picker rendering "Only N left" can never
            disagree with the decision beside it.

            SLOT-1 — volume defence populates this with the headroom left in
            the decision's (caller, postcode zone, hour) cell. Zone-scoped,
            not slot-scoped, and deliberately so: what decides whether this
            customer gets a delivery in this hour is the volume-defence cap
            for THEIR postcode zone across all vendors, not how many of one
            vendor's slots remain.

            WORK-93 (v0.28.0) — the value is CALLER-RELATIVE where the caller
            holds a live hold. A hold consumes a unit of the cell the moment
            `POST /reserve` grants it, so without this the shopper holding a
            slot reads their own hold back as scarcity ("Only 1 left" for a
            cell only they have taken from). When the request carries
            `holdOrderId`, `FEATURE_CHECK_EXCLUDES_OWN_HOLD` is on, and the
            hold is live in THIS decision's (zone, hour) cell, the caller's
            own unit is added back to the number returned TO THEM. Every
            other caller still sees the hold counted, and nothing else moves:
            the stored count is untouched (`/check` is a pure read), the
            available/full decision is unchanged, and at cap the holder's own
            slot still reads 1 (their own unit and nothing more — see
            `holdOrderId`). Absent `holdOrderId`, a hold that is not live, a
            hold in another cell, or the flag off — the number is the plain
            cell headroom, exactly as before.

            WORK-17 (v0.21.0) — when FEATURE_ADJACENCY_DEFENCE is on, the
            value becomes the TIGHTER of two independent constraints:
            min(volume-defence headroom, adjacency capacity), where the
            adjacency capacity is ADJACENCY_BASE_SLOT_CAPACITY less the
            travel penalty from the previous slot's cluster. The two are
            different KINDS of fact — a count of committed orders versus a
            judgement about a driver's day — and are kept on separate axes,
            with the tighter winning.

            Adjacency is TIGHTEN-ONLY. It may lower this number, or introduce
            it for a caller with no configured volume-defence cap WHEN IT
            GENUINELY CONSTRAINS — it never introduces it merely to report
            that nothing is wrong. A slot adjacency did not penalise carries
            no value, exactly as before WORK-17. Present only on
            `decision: "available"` either way.

            ABSENT still never means "zero left". It means no constraint was
            computed — no volume-defence cap and (with adjacency on) no
            readable anchor index for that day. Consumers must treat absence
            as "render no label", not as scarcity.
        expectedDispatchType:
          type: string
          enum: [anchor-now, route-later, batching]
          description: >-
            WORK-9 (v0.8.0) — advisory field, optional. Present only on
            `decision: "available"` where a dispatch shape is meaningful;
            omitted on Unavailable. `batching` when the anchor driver's
            detour is 0 (zero-detour bonus or activeAnchorMatch true) —
            the "one driver, multiple deliveries" case. `anchor-now` when
            a route-insertion driver is ready. `route-later` when
            permissive accepts on capacity alone and no driver-ready
            anchor was found — dispatch will figure it out later.
        cluster:
          type: boolean
          description: >-
            WORK-15 (v0.12.0 Slice 1) — advisory field, optional. Present
            when the request carried a `pendingOrderDistribution` and
            `FEATURE_PREBOOK_CLUSTERING=true`. True iff this slot's
            zone+hour cell qualifies as a pre-book cluster given the
            caller's distribution — i.e. the cell's existing-order count
            is ≥ N (default 1) and ≤ M (default 5), and the slot passed
            all eligibility filters. Absent (or false) when the flag is
            off, the distribution is missing, or the cell doesn't qualify.
            Orthogonal to `activeAnchorMatch` (which reads live driver
            state) — `cluster` reads only the caller-supplied pre-book
            distribution.
        appliedBias:
          type: boolean
          description: >-
            WORK-15 (v0.12.0 Slice 1) — advisory field, optional. True
            iff this slot qualified as a cluster AND passed the
            eligibility filters — i.e. a candidate the consumer may wish
            to rank up. DESPITE THE NAME it does not mean Slotster applied
            anything: no reordering or re-scoring happens, and
            `decisions[]` is always returned in `slotRequests` input
            order. The name is retained because it is part of the
            published v1 contract. False on non-cluster slots, on slots
            the eligibility filters excluded, and when the flag is off. A
            slot with `cluster: true` but `appliedBias: false` indicates
            eligibility filtering suppressed it — see
            `clusterSuppressionReason` for the reason.
        clusterSuppressionReason:
          type: string
          enum: [below_n, cell_over_m, eligibility_filtered]
          description: >-
            WORK-15 (v0.12.0 Slice 1) — advisory field, optional. Present
            only when a `pendingOrderDistribution` was attached AND the
            slot did NOT qualify as a cluster (cluster false). Names why:
            `below_n` — cell count is below the threshold; `cell_over_m` —
            cell count exceeds the absorption ceiling; `eligibility_filtered`
            — the slot was excluded by an upstream eligibility filter
            (chilled, catchment, capacity) before clustering could apply.
            Absent on slots where cluster is true, on requests with no
            distribution, and when the flag is off. Load-bearing for
            demo/tuning: with N and M as tuneable config, the suppression
            reason is what an operator inspects to see why bias is or is
            not landing on a given cell.
    CheckSuccess:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [decisions]
          properties:
            decisions:
              type: array
              items: { $ref: "#/components/schemas/SlotDecision" }
    ErrorEnvelope:
      type: object
      required: [success, message]
      properties:
        success: { type: boolean, enum: [false] }
        message: { type: string }
        details:
          oneOf:
            - type: array
              items:
                type: object
                properties:
                  path: { type: string }
                  message: { type: string }
            - type: object
              required: [cause]
              properties:
                cause: { type: string }
    VolumeDefenceReopenBody:
      type: object
      required: [decision, callerId, zone, hourWindow, actor, newCap]
      properties:
        decision: { type: string, enum: [reopen] }
        callerId:
          type: string
          minLength: 1
          maxLength: 64
          description: Subject-consumer identifier — the caller whose cap tripped (Cotta), NOT the ops caller.
        zone:
          type: string
          description: Postcode outward-code zone that tripped (e.g. `SE1`).
        hourWindow:
          type: string
          format: date-time
          description: ISO-8601 hour-window start; matches the value in the SC-4 notification payload.
        actor:
          type: string
          minLength: 3
          maxLength: 200
          description: Operator identifier — email or handle. Free-form; recorded verbatim in the decision audit trail.
        newCap:
          type: integer
          minimum: 1
          maximum: 1000000
          description: New effective cap for the remainder of the hour. Supersedes the configured cap. Count is NOT reset.
    VolumeDefenceHoldClosedBody:
      type: object
      required: [decision, callerId, zone, hourWindow, actor]
      properties:
        decision: { type: string, enum: [hold_closed] }
        callerId: { type: string, minLength: 1, maxLength: 64 }
        zone: { type: string }
        hourWindow: { type: string, format: date-time }
        actor: { type: string, minLength: 3, maxLength: 200 }
    VolumeDefenceReopenResponse:
      type: object
      required: [decision, callerId, zone, hourWindow, newCap, clearedTempClose, decidedAt]
      properties:
        decision: { type: string, enum: [reopen] }
        callerId: { type: string }
        zone: { type: string }
        hourWindow: { type: string, format: date-time }
        newCap: { type: integer }
        clearedTempClose: { type: boolean, enum: [true] }
        decidedAt: { type: string, format: date-time }
    VolumeDefenceHoldClosedResponse:
      type: object
      required: [decision, callerId, zone, hourWindow, decidedAt]
      properties:
        decision: { type: string, enum: [hold_closed] }
        callerId: { type: string }
        zone: { type: string }
        hourWindow: { type: string, format: date-time }
        decidedAt: { type: string, format: date-time }
    SlotEventRequest:
      oneOf:
        - $ref: "#/components/schemas/SlotEventActivated"
        - $ref: "#/components/schemas/SlotEventReleased"
        - $ref: "#/components/schemas/SlotEventOrderCommitted"
      discriminator:
        propertyName: type
        mapping:
          activated: "#/components/schemas/SlotEventActivated"
          released: "#/components/schemas/SlotEventReleased"
          order_committed: "#/components/schemas/SlotEventOrderCommitted"
    SlotEventActivated:
      type: object
      required:
        [type, slotId, anchorDriverId, orderId, customerPostcodeZone, slotWindowStart, slotWindowEnd, validUntil]
      additionalProperties: false
      properties:
        type:
          type: string
          enum: [activated]
        slotId: { type: string, minLength: 1 }
        anchorDriverId: { type: string, minLength: 1 }
        orderId: { type: string, minLength: 1 }
        customerPostcodeZone:
          type: string
          pattern: '^[A-Z]{1,2}[0-9]{1,2}[A-Z]?$'
          description: >-
            Customer's London postcode district (WORK-4). Anchor store keys
            by this zone so lookupByZone can match same-zone and adjacent-zone
            anchors during /check.
        slotWindowStart: { type: string, format: date-time }
        slotWindowEnd: { type: string, format: date-time }
        validUntil:
          type: string
          format: date-time
          description: >-
            Caller-supplied expiry for this active-anchor entry. Redis
            TTL is derived from it. Must be strictly in the future at
            request time; otherwise the request is rejected with 400.
    SlotEventReleased:
      type: object
      required: [type, slotId, anchorDriverId, orderId, customerPostcodeZone]
      additionalProperties: false
      properties:
        type:
          type: string
          enum: [released]
        slotId: { type: string, minLength: 1 }
        anchorDriverId: { type: string, minLength: 1 }
        orderId: { type: string, minLength: 1 }
        customerPostcodeZone:
          type: string
          pattern: '^[A-Z]{1,2}[0-9]{1,2}[A-Z]?$'
          description: >-
            Customer's London postcode district (WORK-4). Same value passed
            at activate — needed to construct the same key on release.
        dispatchId:
          type: string
          format: uuid
          description: >-
            WORK-10 (v0.9.1) — optional. Caller echoes the dispatchId
            they received in the activate response. When present + local
            release actually removed anchor state (orderId match) +
            FEATURE_DISPATCH_PROVIDERS=true + caller is in a registered
            marketplace, Slotster fires an outbound
            `DELETE /dispatches/{dispatchId}` to the resolved provider
            (fail-soft). Absent → no outbound DELETE (BC).
    SlotEventOrderCommitted:
      type: object
      required: [type, orderId, slotId, deliveryAddress, dropoffStartTime, dropoffEndTime]
      additionalProperties: false
      properties:
        type:
          type: string
          enum: [order_committed]
        orderId: { type: string, minLength: 1 }
        slotId: { type: string, minLength: 1 }
        deliveryAddress:
          type: object
          required: [pincode]
          additionalProperties: false
          properties:
            pincode:
              type: string
              minLength: 1
              maxLength: 12
              description: >-
                Raw postcode from Cotta's ChildOrder.deliveryAddress.
                Slotster derives the postcode zone by trimming to the
                outward code and validating against the standard zone
                pattern (see #/components/schemas/SlotEventActivated
                customerPostcodeZone). Zones outside Slotster's coverage
                map are accepted as no-op (stored:false).
        dropoffStartTime:
          type: string
          format: date-time
          description: >-
            Start of the delivery slot window. Used to derive the hour
            boundary key in the delivery-count sidecar store.
        dropoffEndTime:
          type: string
          format: date-time
          description: >-
            End of the delivery slot window. Used to compute the count
            key's TTL (Redis expiry = dropoffEndTime + 2 hours), and as
            the customer anchor's validUntil.
        customerLocation:
          $ref: "#/components/schemas/Coordinate"
          description: >-
            SLOT-1 Slice 27c-i — the delivery point for this order, so a
            customer anchor can carry a real coordinate rather than only a
            postcode zone. OPTIONAL and additive: when absent the anchor is
            written exactly as it is today (zone only), which is why a
            consumer that does not send it sees no behaviour change.

            Cotta already resolves this via geocodePostcodeCached and sends
            the same value as customerLocation on POST /check.

            Never persisted. The coordinate is stripped structurally at the
            persistence boundary (redactForPersistence) along with every
            other coordinate-shaped value, so it reaches Redis for the life
            of the slot window and never reaches Postgres.

            Nothing reads this for a decision as of 27c-i. Slice 27c-ii uses
            it to decide cluster membership by radius.
      description: >-
        WORK-12 Slice 2 — Cotta emits this event at ChildOrder commit-path
        so Slotster can maintain a per-consumer per-area per-hour
        delivery count. Feature-flag-gated on the Slotster side: when
        FEATURE_VOLUME_DEFENCE=false OR the caller has no volume-defence
        config, the endpoint returns
        stored:false without side-effect. Since WORK-82 the caller's config
        is resolved per business, as for /check: only the caps, default
        cap or group caps in its business's published config (there is no
        env consumer config). A business config that turns volume defence off reads as
        no config (dark surface for consumers
        who compose the capability out). When flag on + caller
        configured, Slotster derives the zone from pincode, computes
        the hour-window from dropoffStartTime, and increments a
        counter keyed by (callerId, zone, hourWindow). Idempotent
        via SET NX on orderId dedup key. Slice 3 will read the count
        during /check enforcement.
    SlotsPrecheckResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [zone, day, valid]
          properties:
            zone:
              type: string
              pattern: "^[A-Z]{1,2}[0-9]{1,2}[A-Z]?$"
            day:
              type: string
              format: date-time
              description: >-
                Echoes the request's `day` normalised to UTC — midnight
                when a date-only value was sent.
            valid:
              type: boolean
              description: >-
                False only when every volume-defence window a deliverable
                hour of the day falls in is at or over its effective cap for
                this caller and zone. Always true for a caller/zone
                volume-defence does not govern.
            openHours:
              type: array
              uniqueItems: true
              items:
                type: integer
                minimum: 0
                maximum: 23
              description: >-
                WORK-83. The deliverable hours of the day (Europe/London
                wall-clock, ascending) whose volume-defence window still has
                headroom — the hours a slot starting then could still be
                booked in as far as volume defence is concerned. Windows open
                and close as a unit, so at `VD_WINDOW_HOURS` > 1 all
                deliverable hours sharing a window appear or disappear
                together. For a zone volume defence governs, `valid` is
                exactly `openHours` being non-empty. For a caller or zone it
                does not govern, every deliverable hour is listed. Always
                emitted by this version; optional in the schema so a consumer
                talking to an older Slotster that omits it must treat an
                absent field as "no hour information" and not narrow
                anything.
    SlotEventSuccess:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [stored]
          properties:
            stored:
              type: boolean
              description: >-
                Business outcome. true on successful activate (write
                landed); true on matched release (guarded delete
                executed); false on release-with-no-match (either no
                stored state OR orderId mismatch).
            dispatchId:
              type: string
              format: uuid
              description: >-
                WORK-10 (v0.9.1) — present on activate responses when
                the outbound dispatch was attempted (FEATURE_
                DISPATCH_PROVIDERS=true AND caller in a registered
                marketplace). Slotster-generated UUIDv4; provider
                echoes in its 2xx body. Absent when the outbound flow
                was skipped (flag off, caller not in a marketplace, or
                local activate returned stored:false).
            dispatchStatus:
              type: string
              enum: [created, pending, failed]
              description: >-
                WORK-10 (v0.9.1) — present when the outbound dispatch
                was attempted. `created` = provider returned 2xx with a
                matching dispatchId echo. `pending` = provider timeout
                / network unreachable (retry-safe; inbound webhook
                reconciles). `failed` = provider non-2xx / parse-error
                / dispatchId echo mismatch. Activation never hard-fails
                on outbound error; the anchor state always commits.
                Absent when outbound flow was skipped.
    # WORK-50 Slice 2a + Slice 9 — checkout hold (/reserve + /release).
    # Bounds below mirror src/validators/reserveValidation.ts exactly;
    # a divergence between the two is a bug, not a rounding.
    ReserveRequest:
      type: object
      required: [orderId, pincode, dropoffStartTime, dropoffEndTime]
      properties:
        orderId:
          type: string
          minLength: 1
          maxLength: 128
          pattern: '^[\x21-\x7E]+$'
          description: >-
            The consumer's order identifier. Trimmed. Printable ASCII, no
            whitespace (it lands verbatim in a Redis key). This is the dedup key —
            the SAME identifier that must later appear on the `/slots/events`
            `order_committed` event, or the commit will double-count.
          example: order-42
        pincode:
          type: string
          minLength: 1
          maxLength: 16
          description: >-
            Customer postcode. Reduced to its outward zone (e.g. `SW11`)
            server-side; a value that does not reduce to a zone yields
            `reason: unmanaged`.
          example: SW11 1AA
        dropoffStartTime:
          type: string
          format: date-time
          description: >-
            Slot start, ISO-8601. Truncated to the UTC hour to derive the
            `hourWindow` half of the count key — the same derivation
            `/slots/events order_committed` and the `/check` volume-defence
            gate use. The WORK-90 fleet check matches it as an exact string
            against committed orders' `dropoffStartTime`, so send it in the
            same form as on `order_committed`.
          example: "2026-08-21T14:00:00.000Z"
        dropoffEndTime:
          type: string
          format: date-time
          description: >-
            Slot end, ISO-8601. Caps the hold: `ttl = min(300s, time until
            this instant)`. An instant already in the past yields
            `reason: unmanaged` (nothing is claimed for a slot that has
            already ended).
          example: "2026-08-21T15:00:00.000Z"
        customerLocation:
          $ref: "#/components/schemas/Coordinate"
          description: >-
            WORK-90 — optional delivery point, the same value sent as
            `customerLocation` on `POST /check`. When present, the
            fleet-feasibility check measures from it exactly as `/check`
            does, so the two agree. When absent, the postcode district's
            centre is used. Never logged or persisted.
    ReserveSuccess:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [reserved, reason, remaining, holdExpiresAt]
          properties:
            reserved:
              type: boolean
              description: >-
                Whether the caller may proceed with this slot. True for both
                `reserved` and `unmanaged`; false for `slot-full`,
                `temp-closed` and `fleet-infeasible`.
            reason:
              type: string
              enum: [reserved, slot-full, temp-closed, fleet-infeasible, unmanaged]
              description: >-
                `reserved` — one unit of zone-hour capacity is now held for
                this orderId (or the orderId already held one).
                `slot-full` — the count has reached the cap.
                `temp-closed` — a sticky volume-defence temp-close is in force
                on this `(caller, zone, hourWindow)`. The flag lapses on its own
                TTL and re-arms while the zone is still full, so the refusal
                persists for as long as the zone is genuinely full; an ops
                `reopen` via `POST /volume-defence/decisions` clears it
                immediately. `/reserve` reads the flag and never sets it.
                `fleet-infeasible` — WORK-90: serving this customer in this
                window would need more distinct delivery areas than the day's
                driver count (the same verdict `/check` gives as
                `slot_capacity_full`). Nothing is claimed.
                `unmanaged` — outside the capability's remit; nothing claimed.
            remaining:
              type: [integer, "null"]
              minimum: 0
              description: >-
                Headroom left in the zone-hour after this call — `cap - count`,
                floored at 0. `0` on `slot-full` / `temp-closed`. **`null` on
                `unmanaged` and `fleet-infeasible`**, which is a distinct
                statement from `0`: the zone-hour count was not consulted.
            holdExpiresAt:
              type: [string, "null"]
              format: date-time
              description: >-
                ISO-8601 instant the hold expires; after it, the capacity
                returns to the pool with no further call. Non-null only when
                `reason` is `reserved`; null for `slot-full`, `temp-closed` and
                `unmanaged`. On an idempotent re-reserve this is recomputed
                from the request instant and is an upper bound, not the exact
                expiry of the original hold.
    ReleaseRequest:
      type: object
      required: [orderId, pincode, dropoffStartTime]
      properties:
        orderId:
          type: string
          minLength: 1
          maxLength: 128
          pattern: '^[\x21-\x7E]+$'
          description: The orderId whose hold is being released. Trimmed. Printable ASCII, no whitespace.
          example: order-42
        pincode:
          type: string
          minLength: 1
          maxLength: 16
          description: >-
            Must reduce to the same zone the reserve used, or the release
            cannot find the claim.
          example: SW11 1AA
        dropoffStartTime:
          type: string
          format: date-time
          description: >-
            Must fall in the same UTC hour-window the reserve used. Note there
            is no `dropoffEndTime` here — the release derives its keys without
            one.
          example: "2026-08-21T14:00:00.000Z"
    ReleaseSuccess:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [released]
          properties:
            released:
              type: boolean
              description: >-
                True when a claim was cleared (dedup deleted, count
                decremented). False on an idempotent no-op — the order held no
                reservation, or the `pincode` did not reduce to a zone.
            reason:
              type: string
              enum: [unmanaged]
              description: >-
                Present ONLY on the short-circuit where `pincode` does not
                reduce to a postcode zone, alongside `released: false`. Absent
                on every other response — do not branch on its presence for
                the ordinary no-op case, which returns `released: false` with
                no `reason`.
    # SLOT-1 Slice 11 — batcave read API row + envelope schemas. All
    # four envelopes share the same shape ({success, data:{rows,
    # nextCursor}}); the rows array is typed per endpoint.
    CheckDecisionRow:
      type: object
      required: [id, reqId, callerId, requestBody, responseBody, latencyMs, createdAt]
      properties:
        id: { type: string, format: uuid }
        reqId: { type: string }
        callerId: { type: string }
        requestBody: { type: object, additionalProperties: true }
        responseBody: { type: object, additionalProperties: true }
        latencyMs: { type: integer, minimum: 0 }
        createdAt: { type: string, format: date-time }
    SlotEventRow:
      type: object
      required: [id, reqId, callerId, type, payload, createdAt]
      properties:
        id: { type: string, format: uuid }
        reqId: { type: string }
        callerId: { type: string }
        type:
          type: string
          enum: [activated, released, order_committed]
        payload: { type: object, additionalProperties: true }
        stored:
          type: [boolean, "null"]
          description: >-
            activated → recordDelivery result.stored; released →
            release() deleted; order_committed → true when the delivery
            counted, false when short-circuited on the dark surface.
        createdAt: { type: string, format: date-time }
    VolumeDefenceDecisionRow:
      type: object
      required: [id, reqId, opsCallerId, subjectCallerId, zone, hourWindow, decision, actor, decidedAt]
      properties:
        id: { type: string, format: uuid }
        reqId: { type: string }
        opsCallerId:
          type: string
          description: The authenticated caller that issued the decision.
        subjectCallerId:
          type: string
          description: The consumer whose cap the decision applies to.
        zone: { type: string }
        hourWindow: { type: string, format: date-time }
        decision:
          type: string
          enum: [reopen, hold_closed]
        actor:
          type: string
          description: Free-form ops identifier from the decision body.
        newCap:
          type: [integer, "null"]
          minimum: 1
          description: The new cap value (reopen only; null on hold_closed).
        decidedAt: { type: string, format: date-time }
    VolumeDefenceStateTransitionRow:
      type: object
      required: [id, callerId, zone, hourWindow, transitionType, transitionedAt]
      properties:
        id: { type: string, format: uuid }
        reqId:
          type: [string, "null"]
          description: >-
            Nullable — trip rows fire from inside /check's per-slot
            loop and the row is written outside the request's error
            boundary. Reopen rows always have it. Slack-driven decision
            rows use `slack:${trip_id}`.
        callerId: { type: string }
        zone: { type: string }
        hourWindow: { type: string, format: date-time }
        transitionType:
          type: string
          enum: [trip, reopen, hold]
        oldCap:
          type: [integer, "null"]
          minimum: 1
          description: Trip → the cap that was hit; reopen → null (already in decisions row).
        newCap:
          type: [integer, "null"]
          minimum: 1
          description: Trip → null (cap unchanged); reopen → the new cap.
        countAtTransition:
          type: [integer, "null"]
          minimum: 0
          description: Trip → attempted count (cap + 1); reopen/hold → null.
        transitionedAt: { type: string, format: date-time }
    ReadCheckDecisionsResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [rows, nextCursor]
          properties:
            rows:
              type: array
              items: { $ref: "#/components/schemas/CheckDecisionRow" }
            nextCursor:
              type: [string, "null"]
              format: date-time
              description: >-
                Echo as `?cursor=<iso>` on the next request. `null`
                when the response is short of `limit` (no more rows).
    ReadSlotEventsResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [rows, nextCursor]
          properties:
            rows:
              type: array
              items: { $ref: "#/components/schemas/SlotEventRow" }
            nextCursor:
              type: [string, "null"]
              format: date-time
    ReadVolumeDefenceDecisionsResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [rows, nextCursor]
          properties:
            rows:
              type: array
              items: { $ref: "#/components/schemas/VolumeDefenceDecisionRow" }
            nextCursor:
              type: [string, "null"]
              format: date-time
    ReadVolumeDefenceStateTransitionsResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [rows, nextCursor]
          properties:
            rows:
              type: array
              items: { $ref: "#/components/schemas/VolumeDefenceStateTransitionRow" }
            nextCursor:
              type: [string, "null"]
              format: date-time
    JourneyCoordinate:
      type: object
      required: [lat, lng]
      properties:
        lat: { type: number, minimum: -90, maximum: 90 }
        lng: { type: number, minimum: -180, maximum: 180 }
    JourneyEndpoint:
      type: object
      required: [location]
      properties:
        location: { $ref: "#/components/schemas/JourneyCoordinate" }
    JourneyFeasibilityRequest:
      type: object
      required: [collection, dropoff, collectionWindow]
      properties:
        collection: { $ref: "#/components/schemas/JourneyEndpoint" }
        dropoff: { $ref: "#/components/schemas/JourneyEndpoint" }
        collectionWindow:
          type: object
          required: [start, end]
          properties:
            start: { type: string, format: date-time }
            end:
              type: string
              format: date-time
              description: >-
                Must be strictly after `start`. The arithmetic is anchored
                here, not on the current time.
    JourneyFeasibilitySuccess:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [journey, completability]
          properties:
            journey:
              type: object
              required: [drivenDistanceKm, drivenDurationSec, guardDurationSec, trafficPosture]
              properties:
                drivenDistanceKm:
                  type: number
                  description: >-
                    Driven distance collection to dropoff, kilometres to 3 dp.
                    This is the billable distance — never a straight line.
                drivenDurationSec:
                  type: integer
                  description: OSRM's free-flow driven duration, unadjusted.
                guardDurationSec:
                  type: integer
                  description: >-
                    `drivenDurationSec` multiplied by the traffic posture. This
                    is the figure the guard actually uses.
                trafficPosture: { type: string, enum: [typical, worst_case] }
            completability:
              type: object
              required:
                [completable, reason, projectedCompletionIso, sellableWindowEndIso,
                 serviceAllowanceMinutes, safetyBufferMinutes]
              properties:
                completable: { type: boolean }
                reason:
                  type: string
                  enum: [completable_in_collection_window, exceeds_sellable_window]
                  description: >-
                    LOCAL to this endpoint. Not a member of the `/check` reason
                    enum and never to be mapped as one.
                projectedCompletionIso: { type: string, format: date-time }
                sellableWindowEndIso: { type: string, format: date-time }
                serviceAllowanceMinutes:
                  type: integer
                  description: Applied once per stop — twice in the arithmetic.
                safetyBufferMinutes: { type: integer }
    JourneySlotsConfigSuccess:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          required: [deliveryWindow, disabledHours]
          properties:
            deliveryWindow:
              type: object
              required: [startHour, endHour]
              description: >-
                London wall-clock hours. `endHour` is an EXCLUSIVE upper bound —
                a slot starting at `endHour` is outside the window — so the
                window closes at `endHour:00`.
              properties:
                startHour: { type: integer, minimum: 0, maximum: 23 }
                endHour: { type: integer, minimum: 1, maximum: 24 }
            disabledHours:
              type: array
              description: London wall-clock hours switched off individually.
              items: { type: integer, minimum: 0, maximum: 23 }
