openapi: 3.1.0
info:
  title: Slotster AI
  version: 0.17.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.
  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 the 12 reason enum values from
        `src/services/reasons.ts`; 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`
        AND the demo caller (`sha256(DEMO_PUBLIC_KEY_PLAINTEXT).slice(-8)`)
        is a configured VD consumer via `VOLUME_DEFENCE_CONSUMER_KEYS=demo`.
        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 not configured as a VD consumer, or 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.


        WORK-12 Slice 3 — SEMANTIC SHIFT for volume-defence consumers.
        When `FEATURE_VOLUME_DEFENCE=true` AND the caller is registered
        in `VOLUME_DEFENCE_CONSUMER_KEYS` AND the requested slot's zone
        has a configured cap, `/check` transitions from
        **pure-read** to **reserve-and-recommend**: the endpoint
        atomically increments an internal per-`(callerId, zone, hour)`
        counter and returns the slot as `unavailable` with
        `reason: slot_capacity_full` if the cap would be breached. The
        reserve auto-expires after ~60 seconds; Cotta's later
        `POST /slots/events order_committed` further increments the
        same counter (bounded temporary over-count during the reserve
        window is accepted per brief § 5 — the temp-close sticky flag
        catches the actual cap breach so subsequent /checks still
        refuse).


        Callers not registered as volume-defence consumers, and slots
        whose zone is not in the consumer's caps map, are unaffected —
        the gate short-circuits with no Redis I/O (SC-5 composability).
      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.
          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" }
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
  # 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:
    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`, biases ranking toward
            clustered slots (zone+hour cell count ≥ N; per-consumer N
            config, default 1). Bias stops applying above M (default 5).
            Absent, malformed, or clustering-throws all degrade to today's
            behaviour (no chips, no bias). 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
    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.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
        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.
        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 the ranking bias was actually applied to this decision
            (composition precedence: eligibility filters passed AND
            cluster was true). 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 the bias — 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).
      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 is not in the
        volume-defence consumer config, the endpoint returns
        stored:false without side-effect (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.
    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.
    # 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
