openapi: "3.1.0"

info:
  title: Capillary Trading & Market-Data API
  version: "0.1.0"
  contact:
    name: Capillary Platform Team
    email: platform@capillary.demo
  description: |
    The Phase-1 / Demo-M2 **trading and market-data** surface served by the stateless edge
    binary (`cmd/cap-api`). It is the live counterpart to the cap-table register surface
    (`cap-table.yaml`): quotes, order submission, positions, and trade/clear history, plus a
    WebSocket live feed.

    **Source of truth.** This OpenAPI document defines the contract. The REST/WebSocket handlers
    in package `api` implement to this spec; no handler deviates from the shapes, status codes, or
    semantics here without revising this document first (#23).

    **Architecture (stateless edge, CQRS — tech_spec §4.1, §3.2).** The edge is stateless and
    horizontally scalable. Order **writes route THROUGH the core's single-writer per-instrument
    sequencer** (invariant 0: an order's batch is its marker-relative stream position, never a
    wall-clock comparison). **Reads are served from a projection / read model**, never the write
    store. Handlers validate + translate only — the economics live in the matching core and ledger.

    **Money.** Every monetary and quantity field is an **integer** — minor units (e.g. euro cents),
    whole shares, or half-units — **never a float** (§3.1). Fields suffixed `Half` carry 2× the
    minor-unit price (the exact uniform-clearing band midpoint); divide by two for the minor-unit
    price, which is whole iff the half value is even.

    **Guest accounts.** For the demo there is **no Keycloak**: `POST /guest` mints a zero-friction,
    throwaway play-money account per visitor. The endpoints below are unauthenticated. The
    Authelia/Keycloak gate guards only the admin/cap-table surface (see `cap-table.yaml`).

    **Batches.** Orders are accepted into the current open batch (202) and clear at the next
    batch cut. The live feed reports each clear, its price, and its trades.

    **Operational endpoints (outside this versioned API):** `GET /healthz` returns the literal
    `ok` (the deploy-health check matches on it); `GET /metrics` (separate port) exposes the
    engine's Prometheus throughput metrics.

servers:
  - url: /api/v1
    description: This server (same origin — use this to try requests live from the docs)
  - url: http://localhost:8080/api/v1
    description: Local development
  - url: https://cap-demo.mair.io/api/v1
    description: Staging / demo

# No bearer auth for the demo trading surface — guest play-money accounts.
security: []

components:
  parameters:
    InstrumentPath:
      name: instrument
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/InstrumentId"
      description: The instrument (market) identifier.

    HistoryLimit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
      description: Maximum number of history items to return (1–200, default 50).

  schemas:
    # ── Identifiers ───────────────────────────────────────────────────────
    InstrumentId:
      type: string
      minLength: 1
      maxLength: 64
      description: Instrument / market identifier.
      example: "GREENVOLT"

    AccountId:
      type: string
      pattern: "^[0-9A-HJKMNP-TV-Z]{26}$"
      description: ULID referencing a play-money account.
      example: "01ARZ3NDEKTSV4RRFFQ69G5FAV"

    # ── Money / quantities (always integer, never float) ──────────────────
    MinorUnits:
      type: integer
      description: A monetary amount in integer minor units (e.g. euro cents). Never a float (§3.1).
      example: 1000000

    PriceHalf:
      type: integer
      minimum: 0
      description: |
        2× a price in minor units — the exact uniform-clearing band midpoint. Divide by two for the
        minor-unit price; it is whole iff this value is even. Never a float (§3.1).
      example: 2000

    Currency:
      type: string
      pattern: "^[A-Z]{3}$"
      example: "EUR"

    Side:
      type: string
      enum: [buy, sell]
      description: Order direction.

    # ── Envelopes ─────────────────────────────────────────────────────────
    ApiError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          example: "INSTRUMENT_NOT_FOUND"
        message:
          type: string
          example: "No such instrument."
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
                example: "/side"
              message:
                type: string
                example: "side must be \"buy\" or \"sell\""

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          $ref: "#/components/schemas/ApiError"

    # ── Domain objects ────────────────────────────────────────────────────
    GuestAccount:
      type: object
      required: [accountId, cash, currency]
      properties:
        accountId:
          $ref: "#/components/schemas/AccountId"
        cash:
          $ref: "#/components/schemas/MinorUnits"
        currency:
          $ref: "#/components/schemas/Currency"

    Instrument:
      type: object
      required: [id, name, currency, batch, lastPriceHalf, hasLast]
      properties:
        id:
          $ref: "#/components/schemas/InstrumentId"
        name:
          type: string
        currency:
          $ref: "#/components/schemas/Currency"
        batch:
          type: integer
          description: The most recently cleared batch number (0 before the first clear).
        lastPriceHalf:
          $ref: "#/components/schemas/PriceHalf"
        hasLast:
          type: boolean
          description: False before the first trade, when lastPriceHalf is not meaningful.

    Quote:
      type: object
      required: [instrument, currency, batch, lastPriceHalf, hasLast]
      properties:
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        currency:
          $ref: "#/components/schemas/Currency"
        batch:
          type: integer
        bidPrice:
          type: [integer, "null"]
          description: AMM bid edge in minor units; null when the pool cannot bid.
        askPrice:
          type: [integer, "null"]
          description: AMM ask edge in minor units; null when the pool cannot offer.
        lastPriceHalf:
          $ref: "#/components/schemas/PriceHalf"
        hasLast:
          type: boolean

    OrderRequest:
      type: object
      required: [accountId, side, quantity]
      properties:
        accountId:
          $ref: "#/components/schemas/AccountId"
        side:
          $ref: "#/components/schemas/Side"
        quantity:
          type: integer
          minimum: 1
          description: Order quantity in whole shares.
        limit:
          type: integer
          minimum: 1
          description: |
            Limit price in integer minor units. Required for every kind except `market` (a market
            order carries no limit — it fills at the clearing price).
        kind:
          type: string
          enum: [ioc, gtc, fok, market]
          description: |
            Order discipline (takes precedence over `gtc` when set; omit to fall back to `gtc`).
            `ioc` immediate-or-cancel (default); `gtc` good-till-cancel (rests); `fok` fill-or-kill
            (the FULL quantity fills in the arrival batch or the order is killed entirely); `market`
            fills at the next batch's uniform clearing price (no `limit` needed). In this batch
            auction a market order is sent as a marketable limit at the current quote, so it clears
            at ~the quote rather than skewing the price.
        gtc:
          type: boolean
          default: false
          description: |
            Resting discipline. `false` (the default; omit for the legacy behaviour) is
            immediate-or-cancel: the order fills against its arrival batch and any remainder expires
            at the cut. `true` is good-till-cancel: the unfilled remainder **rests** in the
            instrument's book and is re-offered into every later batch until it fills or is cancelled
            (`DELETE …/orders/{orderId}`). The web GUI defaults humans to `true`; bots post `false`.

    OrderAccepted:
      type: object
      required: [orderId, instrument, seq, status]
      properties:
        orderId:
          type: string
          description: ULID assigned to the accepted order event.
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        seq:
          type: integer
          description: The order's committed position in the instrument's event stream.
        status:
          type: string
          enum: [accepted]
          description: Accepted into the open batch; clears at the next cut.

    CancelAccepted:
      type: object
      required: [cancelId, orderId, instrument, seq, status]
      description: |
        Acknowledges a cancel accepted into the stream. The order leaves the book at the cancel's
        stream position (invariant 0 — not a wall clock); the next batch reflects its removal.
      properties:
        cancelId:
          type: string
          description: ULID assigned to the cancel event.
        orderId:
          type: string
          description: The resting order the cancel withdraws.
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        seq:
          type: integer
          description: The cancel's committed position in the instrument's event stream.
        status:
          type: string
          enum: [accepted]

    RestingOrder:
      type: object
      required: [orderId, instrument, account, side, remaining, original, limit, arrivalSeq]
      description: |
        One open good-til-cancel order still resting in an instrument's book — the rows the GUI's
        "My Open Orders" panel renders. It carries no age: the read edge derives age from the
        order id's embedded ULID timestamp, never from a clock in the deterministic core.
      properties:
        orderId:
          type: string
          description: ULID of the resting order event (also encodes its submit time).
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        account:
          $ref: "#/components/schemas/AccountId"
        side:
          $ref: "#/components/schemas/Side"
        remaining:
          type: integer
          description: Quantity still open (whole shares); only ever decreases as fills land.
        original:
          type: integer
          description: The order's originally submitted quantity (whole shares).
        limit:
          type: integer
          description: Limit price in integer minor units.
        arrivalSeq:
          type: integer
          description: The order's original stream position — its time-priority key in the clear.

    Position:
      type: object
      required: [instrument, shares]
      properties:
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        shares:
          type: integer

    Account:
      type: object
      required: [accountId, cash, currency, positions]
      properties:
        accountId:
          $ref: "#/components/schemas/AccountId"
        cash:
          $ref: "#/components/schemas/MinorUnits"
        currency:
          $ref: "#/components/schemas/Currency"
        positions:
          type: array
          items:
            $ref: "#/components/schemas/Position"

    Trade:
      type: object
      required: [instrument, batch, orderId, account, side, quantity, priceHalf]
      properties:
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        batch:
          type: integer
        orderId:
          type: string
        account:
          $ref: "#/components/schemas/AccountId"
        side:
          $ref: "#/components/schemas/Side"
        quantity:
          type: integer
        priceHalf:
          $ref: "#/components/schemas/PriceHalf"

    Clear:
      type: object
      required: [instrument, batch, traded, priceHalf, quantity, fills, maxBid, hasBid, minAsk, hasAsk]
      properties:
        instrument:
          $ref: "#/components/schemas/InstrumentId"
        batch:
          type: integer
        traded:
          type: boolean
        priceHalf:
          $ref: "#/components/schemas/PriceHalf"
        quantity:
          type: integer
        fills:
          type: integer
        maxBid:
          type: integer
        hasBid:
          type: boolean
        minAsk:
          type: integer
        hasAsk:
          type: boolean

    FeedMessage:
      type: object
      required: [type]
      description: |
        One WebSocket live-feed frame. `type` selects which payload is present — `clear` (always,
        once per batch), `price` (when the batch traded), or `trade` (one per fill).
      properties:
        type:
          type: string
          enum: [clear, price, trade]
        clear:
          $ref: "#/components/schemas/Clear"
        price:
          type: object
          properties:
            instrument:
              $ref: "#/components/schemas/InstrumentId"
            batch:
              type: integer
            priceHalf:
              $ref: "#/components/schemas/PriceHalf"
        trade:
          $ref: "#/components/schemas/Trade"

  responses:
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    BadRequest:
      description: Malformed request body.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    Unprocessable:
      description: Validation failure (per-field details).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
    Forbidden:
      description: The requesting account does not own the resource.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

paths:
  /guest:
    post:
      tags: [Accounts]
      summary: Create a guest play-money account
      operationId: createGuest
      description: |
        Mints a zero-friction, throwaway play-money account seeded with starting cash. No
        authentication — this is the demo's account on-ramp.
      responses:
        "201":
          description: Guest account created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/GuestAccount"

  /instruments:
    get:
      tags: [Market]
      summary: List instruments
      operationId: listInstruments
      responses:
        "200":
          description: All markets the edge runs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Instrument"

  /instruments/{instrument}/quote:
    parameters:
      - $ref: "#/components/parameters/InstrumentPath"
    get:
      tags: [Market]
      summary: Get the current quote
      operationId: getQuote
      description: The current two-sided AMM-backed quote plus the last clearing price.
      responses:
        "200":
          description: Quote.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Quote"
        "404":
          $ref: "#/components/responses/NotFound"

  /instruments/{instrument}/orders:
    parameters:
      - $ref: "#/components/parameters/InstrumentPath"
    post:
      tags: [Trading]
      summary: Submit an order
      operationId: submitOrder
      description: |
        Validates and routes an order through the core's single-writer sequencer into the current
        open batch (invariant 0). It clears at the next cut; the live feed reports the result.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderRequest"
      responses:
        "202":
          description: Order accepted into the open batch.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/OrderAccepted"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"

  /instruments/{instrument}/orders/{orderId}:
    parameters:
      - $ref: "#/components/parameters/InstrumentPath"
      - name: orderId
        in: path
        required: true
        schema:
          type: string
        description: ULID of the resting order to cancel.
    delete:
      tags: [Trading]
      summary: Cancel a resting order
      operationId: cancelOrder
      description: |
        Withdraws a resting good-til-cancel order. The owning account is supplied as the `accountId`
        query parameter (the demo has no session). The exchange **authorises that the account owns
        the still-resting order**, then appends a cancel through the same single-writer sequencer as
        orders (invariant 0): the withdrawal takes effect at the cancel's stream position, and the
        next batch reflects the removal. Cancelling an order that is not resting (unknown, already
        filled, or already cancelled) is a 404; a non-owner is a 403.
      parameters:
        - name: accountId
          in: query
          required: true
          schema:
            $ref: "#/components/schemas/AccountId"
          description: The account that owns the order (cancel authorisation).
      responses:
        "202":
          description: Cancel accepted into the stream.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/CancelAccepted"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"

  /instruments/{instrument}/trades:
    parameters:
      - $ref: "#/components/parameters/InstrumentPath"
      - $ref: "#/components/parameters/HistoryLimit"
    get:
      tags: [Market]
      summary: List recent trades
      operationId: listTrades
      description: The most-recent executed fills from the read model, newest last.
      responses:
        "200":
          description: Recent trades.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Trade"
        "404":
          $ref: "#/components/responses/NotFound"

  /instruments/{instrument}/clears:
    parameters:
      - $ref: "#/components/parameters/InstrumentPath"
      - $ref: "#/components/parameters/HistoryLimit"
    get:
      tags: [Market]
      summary: List recent batch clears
      operationId: listClears
      description: The most-recent batch-clear summaries from the read model, newest last.
      responses:
        "200":
          description: Recent clears.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Clear"
        "404":
          $ref: "#/components/responses/NotFound"

  /instruments/{instrument}/stream:
    parameters:
      - $ref: "#/components/parameters/InstrumentPath"
    get:
      tags: [Market]
      summary: Live market-data WebSocket
      operationId: streamMarketData
      description: |
        Upgrades to a WebSocket and streams the instrument's live feed: a `clear` message per
        batch, a `price` message when the batch traded, and a `trade` message per fill. Each frame
        is a JSON `FeedMessage`. Backpressure is shed at the slow subscriber (oldest message
        dropped), never at the batch path (§4.9).
      parameters:
        - name: Upgrade
          in: header
          required: true
          schema:
            type: string
            enum: [websocket]
        - name: Connection
          in: header
          required: true
          schema:
            type: string
            enum: [Upgrade]
        - name: Sec-WebSocket-Key
          in: header
          required: true
          schema:
            type: string
        - name: Sec-WebSocket-Version
          in: header
          required: true
          schema:
            type: string
            enum: ["13"]
      responses:
        "101":
          description: Switching Protocols — the WebSocket is established; frames are `FeedMessage`.
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: The live feed is at capacity; retry shortly.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

  /accounts/{accountId}:
    parameters:
      - name: accountId
        in: path
        required: true
        schema:
          $ref: "#/components/schemas/AccountId"
    get:
      tags: [Accounts]
      summary: Get an account
      operationId: getAccount
      description: An account's play-money cash and per-instrument share positions, from the read model.
      responses:
        "200":
          description: Account.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Account"
        "404":
          $ref: "#/components/responses/NotFound"

  /accounts/{accountId}/orders:
    parameters:
      - name: accountId
        in: path
        required: true
        schema:
          $ref: "#/components/schemas/AccountId"
    get:
      tags: [Trading]
      summary: List an account's open orders
      operationId: listRestingOrders
      description: |
        The account's open good-til-cancel orders still resting across every instrument's book —
        the data the GUI's "My Open Orders" panel renders. This is a book query, not a read-model
        lookup: an unknown account simply has no resting orders (an empty array, 200), never a 404.
      responses:
        "200":
          description: The account's resting orders (possibly empty).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/RestingOrder"

  /transparency:
    get:
      tags: [Transparency]
      summary: Current signed tree head
      operationId: getTransparencyHead
      description: |
        The Merkle transparency log's current signed tree head: the number of entries (size), the
        Merkle root, and an Ed25519 signature over (size‖root). Every cleared trade batch and every
        corporate action (dividend) is an append-only leaf. Verify the signature with publicKeyHex —
        nothing else is needed.
      responses:
        "200":
          description: Signed tree head (hex-encoded fields).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      size: { type: integer, description: Number of leaves. }
                      rootHex: { type: string, description: Merkle root, hex. }
                      signatureHex: { type: string, description: Ed25519 signature over (size‖root), hex. }
                      publicKeyHex: { type: string, description: Ed25519 public key, hex. }
        "404":
          $ref: "#/components/responses/NotFound"

  /transparency/proof/{index}:
    parameters:
      - name: index
        in: path
        required: true
        schema: { type: integer, minimum: 0 }
        description: Zero-based leaf index in the log.
    get:
      tags: [Transparency]
      summary: Inclusion proof for one entry
      operationId: getTransparencyProof
      description: |
        Everything needed to verify one entry's inclusion WITHOUT trusting this server. Recompute the
        leaf hash SHA-256(0x00‖leaf), fold the audit path (RFC 6962 node hash SHA-256(0x01‖left‖right),
        odd nodes promote; the skip pattern derives from size), and check the result equals rootHex —
        then check signatureHex signs (size‖root) under publicKeyHex.
      responses:
        "200":
          description: Verifiable inclusion bundle (hex-encoded).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      index: { type: integer }
                      size: { type: integer }
                      leafHex: { type: string, description: Original leaf bytes, hex. }
                      proofHex: { type: array, items: { type: string }, description: Audit-path sibling hashes, hex. }
                      rootHex: { type: string }
                      signatureHex: { type: string }
                      publicKeyHex: { type: string }
        "404":
          $ref: "#/components/responses/NotFound"

tags:
  - name: Accounts
    description: Guest play-money accounts and their positions.
  - name: Market
    description: Quotes, instruments, history, and the live feed.
  - name: Trading
    description: Order submission into the batch.
  - name: Transparency
    description: The append-only Merkle transparency log — signed head and inclusion proofs.
