# yaml-language-server: $schema=https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.1/schema.json
#
# Source of truth for the Stellar Index public API.
#
# Every breaking change requires a PR that:
#   1. Updates this file.
#   2. Adds a new /vN path alongside the old one (never edit in place).
#   3. Regenerates docs via `make docs-api`.
#   4. Updates docs/reference/api-design.md if the design intent
#      changed (as opposed to an additive clarification).
#
# Every documented path is implemented in internal/api/v1/ — the
# spec and the live handler set are 1:1 (CI's openapi-handler drift
# check at scripts/ci/lint-docs.sh §11 enforces this). See
# docs/reference/api-design.md for the design rationale + headers
# / rate-limit / observability behaviour the middleware emits.

openapi: 3.1.0
info:
  title: Stellar Index API
  version: "1.0.0"
  summary: Aggregated Stellar asset pricing — real-time and historical.
  description: |
    The Stellar Index API exposes normalised, VWAP-aggregated pricing
    for every classic and SEP-41 Soroban asset on Stellar.

    Design intent: see
    https://github.com/StellarIndex/stellar-index/blob/main/docs/reference/api-design.md

    **Amounts are always strings.** JSON numbers lose precision above
    2^53 and break i128 Soroban token amounts.
  contact:
    name: Stellar Index
    url: https://stellarindex.io
    email: hello@stellarindex.io
  license:
    name: Apache-2.0
    identifier: Apache-2.0
servers:
  - url: https://api.stellarindex.io/v1
    description: Production
  - url: https://api.staging.stellarindex.io/v1
    description: Staging
  - url: http://localhost:3000/v1
    description: Self-hosted / dev

tags:
  - name: health
    description: Liveness + readiness.
  - name: explorer
    description: Network explorer — ledgers, transactions, operations, contracts, search (ADR-0038).
  - name: assets
    description: Asset catalogue + metadata.
  - name: price
    description: Current prices, batch, SSE stream.
  - name: history
    description: Historical TWAP / VWAP series.
  - name: ohlc
    description: OHLC candles.
  - name: markets
    description: Liquidity + venue breakdown per asset.
  - name: oracle
    description: |
      Per-source oracle observations (reflector-dex / -cex / -fx,
      redstone, band) plus SEP-40-shaped passthroughs
      (/oracle/lastprice, /oracle/prices, /oracle/x_last_price)
      that mirror Reflector's contract surface for client SDKs that
      already speak SEP-40.
  - name: account
    description: Self-service for API-key holders.
  - name: auth
    description: |
      Sign-in surfaces — magic-link + 6-digit-code email flows that
      mint the dashboard session cookie, and the SEP-10 Web Auth
      challenge/token pair for wallet-keypair authentication.
  - name: dashboard
    description: |
      Customer dashboard backend (stellarindex.io/dashboard) —
      session-cookie-gated key + webhook management. Browser-facing;
      API-key callers use the /account/* surface instead.
  - name: meta
    description: |
      Metadata about the deployment itself — source catalogue,
      build info — not the trade / oracle / price data.
  - name: protocols
    description: |
      Per-protocol surfaces — the directory + deep-dive backing the
      explorer's Protocols pillar, plus protocol-scoped listings
      (lending pools).
  - name: analytics
    description: |
      Cross-cutting analytics surfaces — auto-flagged MEV events,
      anomalies, divergence. Derived signals over the trade + oracle
      data, backing the explorer's Analytics pillar.

security:
  - {}                          # anonymous allowed on most endpoints
  - APIKeyAuth: []              # API-key authenticated

paths:
  /healthz:
    get:
      tags: [health]
      summary: Liveness — process is up.
      description: |
        Shallow liveness probe. Returns 200 as long as the
        process is running and the mux is serving. Does NOT
        touch the database, Redis, or any downstream
        dependency — those are the readiness probe's job.

        F-1210 (codex audit-2026-05-12): `/healthz` is
        deliberately scoped to the serving-plane process
        liveness, NOT to SLA truth. Use `/v1/status` (the
        comprehensive rollup) for ingest lag, supply
        freshness, per-pair latency, evidence-timer
        coverage, and the rest of the launch-readiness
        signals — those are dashboarded separately because
        liveness probes (k8s, systemd, load balancer pool
        membership) MUST NOT flap when a backfill stalls,
        an external source goes silent, or a non-critical
        timer misfires.
      security: []
      responses:
        "200":
          description: |
            Alive. `status` field is `ok`; a pointer at
            `/v1/status` is included for the SLA-truth rollup.
          content:
            application/json:
              example:
                data:
                  status: ok
                  uptime: 8m53s
                  status_root: /v1/status
                as_of: '2026-07-03T22:37:12.743923086Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/HealthResponse" }

  /readyz:
    get:
      tags: [health]
      summary: Readiness — serving-plane dependencies reachable.
      description: |
        Deep readiness probe scoped to the SERVING PLANE:
        every registered `ReadyChecker` (Postgres, Redis)
        is pinged in parallel under a shared 2-second
        deadline. 200 iff all pass; 503 with a per-check
        failure list otherwise.

        F-1210 (codex audit-2026-05-12): like `/healthz`,
        `/readyz` is deliberately scoped — it answers
        "should the load balancer route traffic to this
        instance?", NOT "is the launch-readiness gate
        green?" Ingest lag, supply freshness, latency
        SLOs, alert state, and evidence-timer coverage are
        the rich-rollup concern at `/v1/status`. An ingest
        stall must NOT pull every API instance out of
        rotation — it would turn a backfill-only outage
        into a customer-facing total outage.

        Operators monitoring launch-readiness should poll
        `/v1/status` (which the Cloudflare-Pages status
        page also consumes). Customers checking "can I
        call the API right now" use `/v1/healthz` (cheap)
        or `/v1/readyz` (deeper, but still serving-plane
        only).
      security: []
      responses:
        "200":
          description: |
            Ready to serve. `data.checks[]` lists each
            dependency-ping result. `data.status` is `ok` when
            every check passed, or `degraded` when a NON-critical
            dependency failed (the API still serves via fallback —
            e.g. Timescale covers a Redis cache miss per ADR-0007 —
            so the backend stays in the load-balancer pool;
            `flags.stale` is `true` in the degraded case).
          content:
            application/json:
              example:
                data:
                  status: ok
                  uptime: 8m54s
                  checks:
                  - name: postgres
                    ok: true
                  - name: redis
                    ok: true
                  status_root: /v1/status
                as_of: '2026-07-03T22:37:13.880270909Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/ReadyEnvelope" }
        "503":
          description: |
            One or more CRITICAL serving-plane dependencies failed
            their ping under the deadline. The response is the same
            enveloped `ReadyResponse` shape as the 200 (NOT
            problem+json): `data.status` is `unready`, `flags.stale`
            is `true`, and `data.checks[]` carries the per-dependency
            breakdown so operators can see which dependency failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReadyEnvelope" }

  /version:
    get:
      tags: [health]
      summary: Build metadata.
      description: |
        Reports what binary is serving: human-readable git-describe
        `version`, `build_date`, full VCS `commit` SHA, `dirty`
        (whether the build tree had uncommitted changes — production
        builds should always be `false`), and the runtime
        `go_version`. Intended for fleet-wide "what's running"
        checks over the API instead of shelling into hosts.
      security: []
      responses:
        "200":
          description: Version string, build date, git SHA.
          content:
            application/json:
              example:
                data:
                  build_date: '2026-07-03T22:24:02Z'
                  commit: 3d26b9d2b1bf72dc3caec9db9dceff59ca24f6b7
                  dirty: 'false'
                  go_version: go1.25.11
                  version: v0.7.6
                as_of: '2026-07-03T22:37:15.007238387Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/VersionResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /status:
    get:
      tags: [health]
      summary: Comprehensive system health rollup for the public status page.
      description: |
        Customer-facing surface — what the showcase status page renders.
        Returns per-service heartbeats (api / indexer / aggregator),
        API histogram-derived p50/p95/p99 latency over the last 5
        minutes, ingest freshness signals, and a count of currently-
        firing Alertmanager incidents grouped by severity.

        Always returns 200; the body's `overall` field reports
        degraded state rather than an HTTP error so monitoring
        dashboards can poll a single endpoint without alerting on
        503s. When the API isn't wired against a Prometheus backend,
        only the in-process surface (region label + uptime) is
        populated and `flags.stale=true`.
      security: []
      responses:
        "200":
          description: Status rollup envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusEnvelope" }
              example:
                data:
                  overall: ok
                  region: { name: r1, deployment: production }
                  services:
                    - { name: api,        status: ok, last_seen: "2026-05-05T15:09:00.116Z" }
                    - { name: indexer,    status: ok, last_seen: "2026-05-05T15:08:46Z" }
                    - { name: aggregator, status: ok, last_seen: "2026-05-05T15:08:47Z" }
                  latency:
                    p50_ms: 0.6
                    p95_ms: 3.85
                    p99_ms: 4.77
                    window_secs: 300
                  freshness:
                    last_aggregator_tick: "2026-05-05T15:08:57Z"
                    active_sources: 13
                    total_sources: 17
                  incidents:
                    active_count: 0
                    page_count: 0
                    ticket_count: 0
                    informational_count: 0
                as_of: "2026-05-05T15:09:00.119Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }

  /assets:
    get:
      tags: [assets]
      summary: List Stellar assets.
      description: |
        Paginated list of **Stellar** assets — native XLM, classic
        credits, Soroban tokens, and verified-catalogue currencies that
        have a Stellar on-chain issuance (USDC, EURC, AQUA, …). Cursor is
        opaque; `limit` is rejected with 400 when outside [1, 500] (NOT
        clamped).

        NON-Stellar assets — fiat currencies (USD, EUR, …) and
        reference-only coins (BTC, ETH, …) that have no Stellar issuance
        — are served by **`GET /external/assets`**, not here (LC-001).
        `asset_class=fiat` therefore returns an empty page on this route.

        The `asset_class` query param is the major dispatch (see its
        own parameter below): `stablecoin` / `crypto` serve a
        class-filtered view of the Stellar catalogue; `all` returns the
        unified Stellar listing; omitting it returns the legacy
        classic-assets page.
      parameters:
        - name: include
          in: query
          required: false
          schema: { type: string }
          description: >-
            Comma-separated row enrichments. Supported: `sparkline7d`
            (per-row 7-day price history for chart columns; one batch
            read per page).
        - name: q
          in: query
          required: false
          schema: { type: string }
          description: >-
            Case-insensitive substring filter over code / asset id /
            slug / name, applied server-side across BOTH phases of the
            unified listing (catalogue + the ~191K classic long tail).
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 500 }
          description: Rows per page (default 100). Page 1 fills from the classic stream when the catalogue is shorter than the limit.
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: asset_class
          in: query
          required: false
          description: |
            Major dispatch for the listing. One of:
            - `fiat` — fiat currencies from the verified-currency catalogue.
            - `stablecoin` — fiat-pegged stablecoins from the catalogue.
            - `crypto` — cryptocurrencies from the catalogue. The
              aliases `blockchain`, `cryptocurrency`, and
              `cryptocurrencies` fold to `crypto` (the explorer's
              filter-chip label).
            - `all` — the unified cross-class listing (every catalogue
              class plus indexed Stellar assets).

            Omitted: the legacy classic-assets page (unfiltered).
          schema:
            type: string
            enum: [fiat, stablecoin, crypto, blockchain, cryptocurrency, cryptocurrencies, all]
        - name: issuer
          in: query
          required: false
          description: |
            Filter the listing to assets minted by the supplied
            issuer G-strkey. Sourced from the same ListCoinsExt
            path the legacy issuer-scoped listing used; rows
            include the full coin-overlay shape (price_usd /
            volume_24h_usd / change_*_pct / etc) when a
            CoinsReader is wired.
          schema:
            type: string
            pattern: "^G[A-Z2-7]{55}$"
      responses:
        "200":
          description: Page of assets.
          content:
            application/json:
              example:
                data:
                - asset_id: xlm
                  type: global
                  code: XLM
                  decimals: 0
                  class: crypto
                  sep1_status: not_applicable
                  name: Stellar Lumens
                  circulating_supply: '339918360790041352'
                  market_cap_usd: '6937310086.48'
                  volume_24h_usd: '2601049.43451797'
                  change_24h_pct: '3.63'
                  price_usd: '0.20401200646499857404'
                  change_1h_pct: '0.01'
                  change_7d_pct: '15.70'
                  slug: xlm
                - asset_id: usdc
                  type: global
                  code: USDC
                  decimals: 0
                  class: stablecoin
                  sep1_status: not_applicable
                  name: USD Coin
                  circulating_supply: '2728642106735257'
                  market_cap_usd: '273375235.46'
                  volume_24h_usd: '2292696.89850489'
                  change_24h_pct: '0.33'
                  price_usd: '0.99989000000000'
                  change_1h_pct: '0.17'
                  change_7d_pct: '0.18'
                  slug: usdc
                as_of: '2026-07-03T22:37:16.207112941Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
                pagination:
                  next: catalogue:2
              schema: { $ref: "#/components/schemas/AssetListEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /external/assets:
    get:
      tags: [assets]
      summary: List non-Stellar (external) assets.
      description: |
        Paginated list of the NON-Stellar assets split off `/assets`
        (LC-001): fiat currencies (USD, EUR, …) and reference-only coins
        (BTC, ETH, …) from the verified-currency catalogue that have no
        Stellar on-chain issuance. These exist to feed the pricing /
        divergence pipeline and are surfaced here for browsing; they are
        deliberately excluded from `/assets`, which is Stellar-only.

        Same wire shape as the catalogue rows on `/assets` (GlobalAssetView:
        `asset_id` = slug, `type` = "global", no issuer/contract_id).
        `market_cap_usd` is populated for fiat (fxHistory-backed).
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
        - name: asset_class
          in: query
          required: false
          description: |
            Optional class filter within the external set: `fiat` or
            `crypto` (reference coins). Omitted returns all external rows,
            market-cap ordered (fiats first).
          schema:
            type: string
            enum: [fiat, stablecoin, crypto, blockchain, cryptocurrency, cryptocurrencies]
      responses:
        "200":
          description: Page of external assets.
          content:
            application/json:
              example:
                data:
                - asset_id: chinese-yuan
                  type: global
                  code: CNY
                  decimals: 0
                  class: fiat
                  sep1_status: not_applicable
                  name: Chinese Yuan
                  circulating_supply: '302000000000000'
                  market_cap_usd: '44486344754441.27'
                  slug: chinese-yuan
                - asset_id: us-dollar
                  type: global
                  code: USD
                  decimals: 0
                  class: fiat
                  sep1_status: not_applicable
                  name: US Dollar
                  circulating_supply: '21700000000000'
                  market_cap_usd: '21700000000000.00'
                  price_usd: '1.00000000000000'
                  slug: us-dollar
                as_of: '2026-07-03T22:37:17.396303097Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
                pagination:
                  next: '2'
              schema: { $ref: "#/components/schemas/AssetListEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /external/assets/{slug}:
    get:
      tags: [assets]
      summary: External asset detail (fiat / reference coin).
      description: |
        `GlobalAssetView` detail for a NON-Stellar asset (fiat currency
        or reference-only coin) by catalogue slug or ticker. The Stellar
        counterpart lives on `/assets/{asset_id}`; a Stellar-issued slug
        (usdc, aqua, …) returns **404** here, and a non-Stellar slug
        returns 404 on `/assets/{asset_id}` — each asset resolves on
        exactly one path (LC-001, no redirect).
      parameters:
        - name: slug
          in: path
          required: true
          description: Catalogue slug (e.g. `us-dollar`, `bitcoin`) or ticker (`USD`, `BTC`).
          schema: { type: string }
      responses:
        "200":
          description: External asset detail.
          content:
            application/json:
              example:
                data:
                  ticker: USD
                  slug: us-dollar
                  name: US Dollar
                  description: Official currency of the United States. Reserve currency for most international trade and…
                  class: fiat
                  coingecko_id: usd
                  price_usd: '1.00000000000000'
                  price_authority: vwap_native
                  price_sources:
                  - identity
                  price_as_of: '2026-07-03T22:37:18.534338873Z'
                  circulating_supply: '21700000000000'
                  market_cap_usd: '21700000000000.00'
                as_of: '2026-07-03T22:37:18.534353688Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/GlobalAssetEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /assets/verified:
    get:
      tags: [assets]
      summary: Verified-currency catalogue listing.
      description: |
        Returns every entry in the binary's verified-currency
        catalogue (`internal/currency/data/seed.yaml`) — the
        hand-curated set of verified Stellar assets (USDC, EURC,
        AQUA, …) that the API surfaces with a "verified" badge.

        Distinct from `/v1/assets` which lists every Stellar-indexed
        asset (verified or not). This endpoint is identity-only —
        no price block — so it's a cheap directory call suitable for
        building a verified-currencies section on a listing page.

        Order matches the seed-file order (deterministic).
      responses:
        "200":
          description: List of verified-currency directory entries.
          content:
            application/json:
              example:
                data:
                - ticker: XLM
                  slug: xlm
                  name: Stellar Lumens
                  class: crypto
                  verified_issuer: Stellar Development Foundation
                  coingecko_id: stellar
                  coinmarketcap_id: '512'
                as_of: '2026-07-03T22:37:19.6906857Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/VerifiedCurrencyListEnvelope" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /assets/{asset_id}:
    get:
      tags: [assets]
      summary: Asset detail — per-Stellar-asset view OR verified-currency global view.
      description: |
        Dispatch depends on the `asset_id` path parameter:

        - **Verified-currency slug WITH a Stellar issuance** (e.g.
          `usdc`, `eurc`, `aqua`) → returns the `GlobalAssetView` shape:
          ticker, slug, name, verified-issuer attribution, and the
          current USD price + price authority. Per-issuance Stellar
          detail lives on the canonical asset_id form below. A slug for
          a NON-Stellar asset (fiat, reference-only coin) returns **404**
          here — its detail lives on `/external/assets/{slug}` (LC-001);
          there is no redirect.
        - **Canonical Stellar asset_id** (`native`, `CODE-G…`,
          `C…` SAC contract, `fiat:CODE`) → returns the existing
          `Asset` shape (per-Stellar-asset detail with SEP-1
          overlay + F2 supply fields).

        Slugs match a hand-curated catalogue
        (`internal/currency/data/seed.yaml`); unknown slugs fall
        through to canonical parsing and return 400 if no canonical
        form matches.
      parameters:
        - { $ref: "#/components/parameters/AssetIdPath" }
      responses:
        "200":
          description: Asset + metadata. Shape depends on whether `asset_id` is a slug or a canonical id.
          content:
            application/json:
              example:
                data:
                  asset_id: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  type: classic
                  code: USDC
                  issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  home_domain: centre.io
                  decimals: 7
                  sep1_status: verified
                  name: USD Coin
                  org_name: Centre Consortium LLC
                  circulating_supply: '2757874444178474'
                  total_supply: '2757874444178474'
                  market_cap_usd: '275787444.42'
                  volume_24h_usd: '2293068.58731944'
                  price_usd: '1.0002795935'
                  change_1h_pct: '-0.09'
                  change_7d_pct: '-0.14'
                  top_markets:
                  - counterparty: native
                    side: quote
                    volume_24h_usd: '1184662.45607940'
                    trade_count_24h: 44334
                  price_history_24h:
                  - t: '2026-07-02T23:00:00Z'
                    p: '1.0018115222'
                  price_history_7d:
                  - t: '2026-06-27T00:00:00Z'
                    p: '1.0007031277'
                  markets_count: 1236
                  trade_count_24h: 286977
                  slug: USDC
                  first_seen_ledger: 34180766
                  last_seen_ledger: 63316034
                as_of: '2026-07-03T22:36:28.712467097Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                oneOf:
                  - $ref: "#/components/schemas/AssetEnvelope"
                  - $ref: "#/components/schemas/GlobalAssetEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /assets/{asset_id}/metadata:
    get:
      tags: [assets]
      summary: Extended metadata (SEP-1 CURRENCIES block).
      description: |
        The asset's SEP-1 `[[CURRENCIES]]` slice on its own —
        same overlay-resolution path as `/v1/assets/{asset_id}`
        but returning ONLY the metadata fields, for clients that
        refresh metadata without re-fetching the pricing core.
        `sep1_status` reports resolver state (`verified` /
        `not_fetched` / `unreachable` / `no_match` /
        `not_applicable`) — resolver failures surface there, not
        as an HTTP error. 404 only when the asset isn't indexed
        at all.
      parameters:
        - { $ref: "#/components/parameters/AssetIdPath" }
      responses:
        "200":
          description: Extended metadata.
          content:
            application/json:
              example:
                data:
                  asset_id: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  home_domain: centre.io
                  sep1_status: verified
                  name: USD Coin
                  description: USDC is a fully collateralized US Dollar stablecoin, based on the open source fiat stablec…
                  image: https://www.centre.io/images/usdc/usdc-icon-86074d9d49.png
                  org_name: Centre Consortium LLC
                  anchor_asset: USD
                  anchor_asset_type: fiat
                as_of: '2026-07-03T22:37:22.027894731Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/AssetMetadataEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }

  /assets/{asset_id}/supply:
    get:
      tags: [assets]
      summary: Live per-token supply (mint − burn − clawback).
      description: |
        Real-time total supply for any token, summed from the ClickHouse
        `supply_flows` lake (decode-at-ingest, ADR-0034): Σmint − Σburn −
        Σclawback over all history, kept current by the indexer's dual-sink
        with no rollup refresh. Amounts are decimal strings in the asset's
        smallest unit (ADR-0003); clients apply per-asset decimals for display.

        Resolution: a Soroban contract id (`C…`) is used directly; a classic
        asset (`CODE-ISSUER`) resolves to its Stellar-Asset-Contract via the
        operator's configured SAC wrappers (404 if unmapped); `native` / `XLM`
        returns the ledger header's `total_coins`
        (`source=ledger_total_coins`), since XLM has no SAC mint/burn events.
      parameters:
        - { $ref: "#/components/parameters/AssetIdPath" }
      responses:
        "200":
          description: Live supply.
          content:
            application/json:
              example:
                data:
                  asset_id: native
                  total_supply: '1054439020873472865'
                  flow_count: 0
                  source: ledger_total_coins
                as_of: '2026-07-03T22:40:11.747021406Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/AssetSupplyEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /assets/{asset_id}/holders:
    get:
      tags: [assets]
      summary: Top holders of an asset by trustline balance.
      description: |
        The accounts holding the largest balances of an asset, plus the total
        count of holders with a positive balance — reconstructed from the
        latest `ledger_entry_changes` trustline per account (ADR-0038 Phase C).
        `asset_id` is the canonical form (`CODE-ISSUER`, or `native`). Balances
        are strings (ADR-0003). Coverage grows with the entry-change capture
        window; full once the Phase-C backfill lands.
      parameters:
        - { $ref: "#/components/parameters/AssetIdPath" }
        - { name: limit, in: query, description: "Maximum rows to return (1-500, default 100). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Ranked holders + total holder count.
          content:
            application/json:
              example:
                data:
                  asset: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  holder_count: 615912
                  holders:
                  - account_id: GDWL5I6SENNVRK7PS7U3CRXIQTWHLFPSBXCGA3TWKTK7AQ7XO6FBXDFG
                    balance: '353017552538442'
                  - account_id: GC5LF63GRVIT5ZXXCXLPI3RX2YXKJQFZVBSAO6AUELN3YIMSWPD6Z6FH
                    balance: '282865538219201'
                as_of: '2026-07-03T22:37:30.738401847Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      asset: { type: string }
                      holder_count: { type: integer, format: int64 }
                      holders:
                        type: array
                        items:
                          type: object
                          properties:
                            account_id: { type: string }
                            balance: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /price:
    get:
      tags: [price]
      summary: Current aggregated price for one asset.
      description: |
        Returns the most-recent VWAP (or last-trade fallback) for the
        `asset` / `quote` pair from the closed-bucket cache.

        Resolution order (handler tries each in turn, takes the first
        non-empty result):

        1. Closed-bucket VWAP from `prices_1m` (the canonical
           aggregated value).
        2. Triangulated value from the Redis VWAP cache. Covers two
           sub-cases: (a) implied chains computed by the
           triangulation worker, surfaced with `flags.triangulated=true`;
           (b) stablecoin-proxy rewrites the aggregator emits at
           tick-time (e.g. `XLM/fiat:USD` synthesised from
           `XLM/USDC-G…`) without a triangulation marker.
        3. Fiat-vs-fiat cross-rate from the forex snapshot when both
           sides are `fiat:` typed (e.g.
           `?asset=fiat:EUR&quote=fiat:USD`). Computed as
           `rate_usd[Y] / rate_usd[X]`. Returned with
           `flags.triangulated=true` since the value is derived
           rather than a direct trade. Same fallback fires on
           `/v1/price/tip`, `/v1/price/batch`,
           `/v1/oracle/lastprice`, and `/v1/oracle/x_last_price`.

        Returns 404 only when every path above misses.
      parameters:
        - name: window
          in: query
          required: false
          schema: { type: string, enum: ["60", "300", "3600", "86400"] }
          description: >-
            Aggregation window in seconds (board #43; proposal: the
            current-price window is query-selectable). Default 60 = the
            closed 1-minute bucket (ADR-0015 semantics, unchanged).
            300/3600/86400 serve the aggregator's continuously-published
            rolling VWAP for that window; a window the aggregator has
            not published for the pair is a 404, never a silent
            substitution. Sub-minute rolling windows: /v1/price/tip.
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
      responses:
        "200":
          description: Current price with freshness flags.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceEnvelope" }
              example:
                data:
                  asset_id: native
                  quote: fiat:USD
                  price: "0.159608357106"
                  price_type: vwap
                  observed_at: "2026-05-05T14:35:00Z"
                  window_seconds: 300
                as_of: "2026-05-05T14:35:42.881Z"
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
        "400":        { $ref: "#/components/responses/BadRequest" }
        "404":        { $ref: "#/components/responses/NotFound" }
        "429":        { $ref: "#/components/responses/RateLimited" }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /price/at:
    get:
      operationId: getPriceAt
      tags: [price]
      summary: Point-in-time price (closed bucket at-or-before a timestamp)
      description: >-
        The pair's closed 1-minute VWAP bucket at-or-before `ts` — the
        cost-basis / PnL / tax-tooling lookup. `observed_at` is the
        BUCKET's close time, never `ts`, so callers see exactly how far
        the nearest observation was; a nearest bucket more than 24 hours
        before `ts` is a 404 (the endpoint refuses to fabricate
        continuity across dead markets). Current price: /v1/price or
        /v1/price/tip.
      parameters:
        - { name: asset, in: query, required: true, schema: { type: string }, description: "Canonical asset id (native | CODE-G... | C... | fiat:XXX)." }
        - { name: quote, in: query, required: false, schema: { type: string }, description: "Quote asset id; default fiat:USD." }
        - { name: ts, in: query, required: true, schema: { type: string, format: date-time }, description: "Historical instant, RFC 3339. Must not be in the future." }
      responses:
        "200":
          description: The closed bucket at-or-before ts.
          content:
            application/json:
              example:
                data:
                  asset_id: native
                  quote: fiat:USD
                  price: '0.18883137679433320865'
                  price_type: vwap
                  observed_at: '2026-07-01T00:00:00Z'
                  window_seconds: 60
                as_of: '2026-07-03T22:37:31.922227287Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/PriceEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /price/tip:
    get:
      tags: [price]
      summary: Rolling-window tip price (ADR-0018 tip surface).
      description: |
        Realtime "what's the price right now" surface per ADR-0018.
        Returns a VWAP over a short rolling window (default 5 s, clamp
        1–60 s); when the window has no trades, falls back to the
        most-recent observed price for the pair.

        Both branches are **in-contract** — `flags.stale` is always
        `false` on this surface. Customers read `price_type` and
        `observed_at` to know what they got. Cross-region consistency
        is **not** provable here; use `/v1/price` for the closed-bucket
        guarantee.

        URL discipline: `?granularity=` is rejected with 400 — that's
        a closed-bucket concept and accepting it would silently change
        the consistency contract.
              FRESHNESS CONTRACT (choosing between /price and /price/tip):
        /price serves the last CLOSED minute bucket (deterministic,
        cache-friendly — up to ~150s behind wall-clock by design,
        ADR-0015); THIS endpoint is the ≤30s-freshness surface. An
        empty rolling window escalates once to 30s (the SLA bound)
        before any closed-bucket fallback, so staleness exceeds 30s
        only when the pair genuinely had no trade in the last 30s —
        read observed_at + window_seconds to know what you got.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - name: window_seconds
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 60
            default: 5
          description: Rolling-window size in seconds for the VWAP.
      responses:
        "200":
          description: Tip price (VWAP or last-good fallback).
          content:
            application/json:
              example:
                data:
                  asset_id: native
                  quote: fiat:USD
                  price: '0.2042667348'
                  price_type: vwap
                  observed_at: '2026-07-03T22:37:33.044745387Z'
                  window_seconds: 30
                as_of: '2026-07-03T22:37:33.045908618Z'
                sources:
                - coinbase
                - bitstamp
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/PriceEnvelope" }
        "400":        { $ref: "#/components/responses/BadRequest" }
        "404":        { $ref: "#/components/responses/NotFound" }
        "429":        { $ref: "#/components/responses/RateLimited" }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /price/tip/stream:
    get:
      tags: [price]
      summary: SSE stream of tip-price updates (ADR-0018).
      description: |
        Server-Sent Events counterpart of `/v1/price/tip`. Same
        compute logic; pushed on a per-connection tick (default
        every `window_seconds` seconds, clamp 1–60 s).

        - First event fires synchronously on connect (no waiting a
          full window).
        - Pre-flight 404 when the pair has no observations: SSE
          can't change status mid-stream, so emptiness is detected
          before the response body switches to `text/event-stream`.
        - Heartbeats every 15 s as comment lines (`:keepalive`) so
          intermediate proxies don't idle out the connection.
        - Resume after disconnect by setting `Last-Event-ID` on the
          reconnect (header preferred, `?last_event_id=` fallback).
        - Same URL-discipline rule as `/v1/price/tip`:
          `?granularity=` returns 400.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - name: window_seconds
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 60
            default: 5
          description: Tick cadence (and rolling-VWAP window) in seconds.
        - name: Last-Event-ID
          in: header
          required: false
          schema: { type: string }
          description: Opaque ID for resuming a previously-broken stream.
      responses:
        "200":
          description: SSE stream of tip_update events.
          content:
            text/event-stream:
              schema: { type: string }
        "400":        { $ref: "#/components/responses/BadRequest" }
        "404":        { $ref: "#/components/responses/NotFound" }
        "429":        { $ref: "#/components/responses/RateLimited" }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /price/batch:
    get:
      tags: [price]
      summary: Current prices for up to 100 assets.
      description: |
        Latest price for each id in `asset_ids` (comma-separated,
        max 100; duplicates de-duplicated server-side). Assets with
        no observation are OMITTED from the response rather than
        failing the batch — a caller asking for 5 assets and
        getting 3 rows knows exactly which 2 lack data. The
        envelope's `flags.stale` is the OR over per-row staleness.
        Above 100 ids, use the POST form (up to 1000 in the body).
      parameters:
        - name: asset_ids
          in: query
          required: true
          schema:
            type: string
            example: "native,USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
          example: "native,USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
          description: |
            Comma-separated canonical asset ids, max 100. Same strict
            form as `/v1/price?asset=` — short symbols are rejected.
        - { $ref: "#/components/parameters/Quote" }
      responses:
        "200":
          description: Batch prices.
          content:
            application/json:
              example:
                data:
                - asset_id: crypto:XLM
                  quote: fiat:USD
                  price: '0.20401200646499857404'
                  price_type: vwap
                  observed_at: '2026-07-03T22:36:00Z'
                  window_seconds: 60
                  change_24h_pct: '+3.62'
                - asset_id: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  quote: fiat:USD
                  price: '1.000000000000'
                  price_type: peg
                  observed_at: '2026-07-03T22:37:34.197270411Z'
                as_of: '2026-07-03T22:37:34.248518093Z'
                sources:
                - bitstamp
                - coinbase
                flags:
                  stale: true
                  reduced_redundancy: false
                  triangulated: true
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/PriceBatchEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      tags: [price]
      summary: Current prices for up to 1000 assets.
      description: |
        Body-parameter form of `GET /price/batch` for large
        watchlists — up to 1000 canonical asset ids in a JSON
        array (URLs would blow past query-string limits well
        before that). Same semantics as the GET form: missing
        observations are omitted, not errored; `flags.stale` is
        the OR over returned rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                asset_ids:
                  type: array
                  items: { type: string }
                  maxItems: 1000
                quote:
                  type: string
                  default: USD
              required: [asset_ids]
      responses:
        "200":
          description: Batch prices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceBatchEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /observations:
    get:
      tags: [price]
      summary: Raw per-source observations (ADR-0018 Surface 3).
      description: |
        Lowest-level surface per ADR-0018: returns the most-recent
        trade from each source that has ever traded the (asset, quote)
        pair. No aggregation, no chaining, no smoothing — purely
        "what each venue last published".

        - `?source=X` narrows to a single source (0- or 1-element array).
        - `?aggregate=latest` collapses to the single newest trade across
          sources (preserves the array wire shape; length 1).
        - `flags.stale` is **always** `false` here — there is no
          aggregation contract to fall short of.
        - Cross-region consistency is **NOT** provable on this surface;
          use `/v1/price` for the closed-bucket guarantee.
        - Empty pair returns 200 with `data: []`, not 404.

        URL discipline: `?granularity=` and `?window_seconds=` return 400
        — those are closed-bucket and tip concepts respectively.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - name: source
          in: query
          required: false
          schema: { type: string }
          description: Restrict to one source's most-recent trade (0/1 row).
        - name: aggregate
          in: query
          required: false
          schema:
            type: string
            enum: [latest]
          description: |
            `latest` collapses to the single most-recent trade across
            all sources. Omit for one row per source.
      responses:
        "200":
          description: Per-source observations array.
          content:
            application/json:
              example:
                data:
                - source: sdex
                  ledger: 63316220
                  tx_hash: 825ede3a206341add5b365d22023de7843b098f710b014de56aa99d29fe7e27b
                  op_index: 1025
                  ts: '2026-07-03T22:42:17Z'
                  base_asset: native
                  quote_asset: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  base_amount: '55653'
                  quote_amount: '11363'
                  price: '0.2041758755'
                as_of: '2026-07-03T22:42:26.862812578Z'
                sources:
                - sdex
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
                  single_source: true
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/TradeRow" }
                    required: [data]
        "400":        { $ref: "#/components/responses/BadRequest" }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /observations/stream:
    get:
      tags: [price]
      summary: SSE stream of raw per-source observations (ADR-0018).
      description: |
        Streaming counterpart of `/v1/observations`. Same compute
        logic; pushed on a per-connection tick (default every 5 s,
        clamp 1–60 s — independent of tip's `window_seconds` because
        observations does not aggregate).

        - First event fires synchronously on connect; data may be
          an empty array (the pair has no observations yet — same
          200/empty contract as the request endpoint, NOT 404).
        - Recurring events fire unconditionally on each tick (no
          server-side dedupe) — clients diff against the previous
          payload if they want change-detection.
        - Heartbeats every 15 s as comment lines.
        - URL discipline: `?granularity=` and `?window_seconds=`
          return 400.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - name: source
          in: query
          required: false
          schema: { type: string }
          description: Restrict to one source's most-recent trade.
        - name: aggregate
          in: query
          required: false
          schema:
            type: string
            enum: [latest]
          description: |
            `latest` collapses to the single most-recent trade across
            all sources.
        - name: interval_seconds
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 60
            default: 5
          description: Tick cadence in seconds.
        - name: Last-Event-ID
          in: header
          required: false
          schema: { type: string }
          description: Opaque ID for resuming a previously-broken stream.
      responses:
        "200":
          description: SSE stream of observations_update events.
          content:
            text/event-stream:
              schema: { type: string }
        "400":        { $ref: "#/components/responses/BadRequest" }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /price/stream:
    get:
      tags: [price]
      summary: SSE stream of closed-bucket price updates (ADR-0015).
      description: |
        SSE counterpart of `/v1/price` — carries the strict ADR-0015
        closed-bucket consistency contract. Unlike `/v1/price/tip/stream`
        and `/v1/observations/stream` (per-connection tick), this
        surface is **Hub-driven**: the aggregator publishes one event
        per closed bucket, and every subscriber on the same
        (asset, quote) pair receives byte-identical payloads — the
        same cross-region consistency guarantee `/v1/price` exposes.

        - Heartbeats every 15 s as comment lines.
        - Resume after disconnect via `Last-Event-ID` (header preferred,
          `?last_event_id=` fallback).
        - URL discipline: `?granularity=` returns 400 — the closed-
          bucket stream is fixed at 1m; use
          `/v1/history/since-inception` for other granularities.
        - 503 when the deployment hasn't wired the streaming Hub
          (typical pre-aggregator state).
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - name: Last-Event-ID
          in: header
          required: false
          schema: { type: string }
          description: Opaque ID for resuming a previously-broken stream.
      responses:
        "200":
          description: SSE stream of price_update events.
          content:
            text/event-stream:
              schema: { type: string }
        "400":        { $ref: "#/components/responses/BadRequest" }
        "429":        { $ref: "#/components/responses/RateLimited" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /history:
    get:
      tags: [history]
      summary: Raw trade history for a pair in a time window.
      description: |
        Returns per-trade records ordered (ts, ledger, tx_hash, op_index, source)
        ascending. Cursor paginates across the full-PK tuple; truncating
        the cursor at (ts, ledger) would drop rows sharing those
        values (high-volume ledgers).

        Bucketed/granularity-based history (VWAP/TWAP series at 1m/15m/...)
        will ship via the aggregator binary (see
        cmd/stellarindex-aggregator) on a different response shape —
        not this endpoint.
      parameters:
        - { $ref: "#/components/parameters/Base" }
        - { $ref: "#/components/parameters/QuoteRequired" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 10000, default: 1000 }
        - { $ref: "#/components/parameters/Cursor" }
      responses:
        "200":
          description: Per-trade records.
          content:
            application/json:
              example:
                data:
                - source: sdex
                  ledger: 63302110
                  tx_hash: a34dfaf2c7a1c1ec8d4f36e9fb693ffc8462285bd9018e97109ae8d039425d3c
                  op_index: 9216
                  ts: '2026-07-03T00:00:17Z'
                  base_asset: native
                  quote_asset: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  base_amount: '4331'
                  quote_amount: '863'
                  price: '0.1992611406'
                as_of: '2026-07-03T22:42:25.518134702Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
                pagination:
                  next: MTc4MzAzNjgyMzAwMDAwMDAwMDo2MzMwMjExMTpzZGV4OjM3NzE2OTY1OTE4YjgxNzI2NzVhYzVhMTBkOGIxMzA4MT…
              schema: { $ref: "#/components/schemas/TradeHistoryEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /history/since-inception:
    get:
      tags: [history]
      summary: Closed-bucket historical VWAP series for one asset/quote pair.
      description: |
        Returns every CLOSED bucket from the requested continuous
        aggregate ladder (`1m`, `15m`, `1h`, `4h`, `1d`, `1w`,
        `1mo`) for the requested asset/quote pair. This is the
        bucketed history surface; `/history` remains the raw
        per-trade endpoint.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - { $ref: "#/components/parameters/Granularity" }
      responses:
        "200":
          description: Since-inception series.
          content:
            application/json:
              example:
                data:
                  asset_id: native
                  quote: fiat:USD
                  price_type: vwap
                  granularity: 1d
                  points:
                  - t: '2021-02-02T00:00:00Z'
                    p: '0.32955352120071295890'
                    v_usd: '0'
                as_of: '2026-07-03T22:37:38.237691998Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: true
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/HistoryEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /chart:
    get:
      tags: [history]
      summary: Rolling-window chart series for an asset/quote pair.
      description: |
        Returns CLOSED CAGG buckets within a rolling time window for
        one asset/quote pair, shaped as `(timeframe, granularity,
        price_type) → points[]` per the V1 chart contract.

        Defaults: `quote=fiat:USD`, `timeframe=24h`,
        `granularity` per the timeframe table below, `price_type=vwap`.

        Default-granularity table (per ADR-0020):

        | Timeframe | Default granularity |
        | --------- | ------------------- |
        | `1h`      | `1m`                |
        | `24h`     | `15m`               |
        | `1w`      | `1h`                |
        | `1mo`     | `4h`                |
        | `1y`      | `1d`                |
        | `all`     | `1d`                |

        `price_type=twap` returns 400 — multi-bar TWAP charts are
        deferred to L7.8 (post-launch), see ADR-0020. Single-bar
        TWAP is available now via `/v1/twap` (true time-weighted
        compute from raw trades); only the multi-bar chart variant
        is the deferred surface.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
        - { $ref: "#/components/parameters/Timeframe" }
        - { $ref: "#/components/parameters/Granularity" }
        - { $ref: "#/components/parameters/PriceType" }
      responses:
        "200":
          description: Chart series.
          content:
            application/json:
              example:
                data:
                  asset_id: native
                  quote: fiat:USD
                  timeframe: 24h
                  granularity: 1h
                  price_type: vwap
                  points:
                  - t: '2026-07-02T23:00:00Z'
                    p: '0.19854703598674192431'
                    v_usd: '41358.47144920'
                  - t: '2026-07-03T00:00:00Z'
                    p: '0.19768864227725027668'
                    v_usd: '29205.97729980'
                  truncated: false
                as_of: '2026-07-03T22:37:39.460980449Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: true
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/ChartEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /ohlc:
    get:
      tags: [ohlc]
      summary: OHLC bar (single window or multi-bar series) for a pair.
      description: |
        Two modes share this route — selected by the presence of the
        `interval` query parameter:

        1. **Single-bar (default — back-compat)**: no `interval`. Returns
           one OHLC bar (`OHLCBar`) for the window `[from, to)` computed
           from raw trades. Single-bar mode 404s on an empty window.
           `truncated=true` means the window exceeded the per-request cap
           (10000 trades) and the bar reflects only the first N.
           `outlier_sigma` (default 4σ) drops dust before bar computation;
           `outlier_sigma=0` to opt into raw extremes.

           Defaults: `from = to - 1h`, `to = now` (clamped to the
           previous 30 s closed-bucket boundary per ADR-0015).

        2. **Multi-bar series (CG/CMC parity, F-0071)**: `interval` is
           one of `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w`.
           Returns `OHLCSeriesResponse` — an `intervals[]` array of
           closed CAGG-backed bars sourced from `prices_<N>`. The
           in-progress bucket is excluded (closed-bucket guard).
           `limit` clamps the bar count (default 100, max 1000).
           Empty window returns 200 + `{intervals: []}` (NOT 404 —
           series clients expect a stable shape across pairs/windows).

           Defaults (series mode):
             - `to`   = now snapped DOWN to the interval's UTC boundary
               (1m → :00, 1h → top of hour, 1d → 00:00 UTC).
             - `from` = `to - limit * interval`.

           5m, 30m, and 4h are CAGG-re-bucketed from finer-grain
           continuous aggregates (5m/30m ← prices_1m, 4h ← prices_1h).
      parameters:
        - { $ref: "#/components/parameters/Base" }
        - { $ref: "#/components/parameters/QuoteRequired" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - name: interval
          in: query
          required: false
          schema:
            type: string
            enum: [1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1mo]
          description: |
            Bar width for multi-bar series mode. Omit for the
            single-bar response over `[from, to)`. Invalid intervals
            return 400 `errors/invalid-interval`.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
          description: |
            Series-mode bar count (max 1000, default 100). Ignored in
            single-bar mode. Invalid values return 400
            `errors/limit-too-large`.
        - name: outlier_sigma
          in: query
          schema: { type: number, minimum: 0, default: 4 }
          description: "Drop trades > N σ from window mean before computing the bar (single-bar mode only). Default 4σ. Pass 0 to disable."
      responses:
        "200":
          description: |
            OHLC response. Wire shape depends on mode:
            single-bar (no `interval`) returns `OHLCEnvelope`;
            multi-bar (`interval` set) returns
            `OHLCSeriesEnvelope`.
          content:
            application/json:
              example:
                data:
                  base: native
                  quote: fiat:USD
                  interval: 1h
                  from: '2026-07-03T20:00:00Z'
                  to: '2026-07-03T22:00:00Z'
                  intervals:
                  - t: '2026-07-03T20:00:00Z'
                    o: '0.2038114923'
                    h: '0.2500000000'
                    l: '0.2034919999'
                    c: '0.2055899576'
                    v_base: '1751598864823776'
                    v_quote: '359971028467214.0000042405'
                    n: 11667
                  - t: '2026-07-03T21:00:00Z'
                    o: '0.2056073590'
                    h: '0.2062000000'
                    l: '0.2033449999'
                    c: '0.2055593613'
                    v_base: '844461854722825'
                    v_quote: '173117716771324.0000032245'
                    n: 8497
                as_of: '2026-07-03T22:37:40.607101334Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: true
                  divergence_warning: false
                  divergence_checked: false
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/OHLCEnvelope" }
                  - { $ref: "#/components/schemas/OHLCSeriesEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: |
            Single-bar mode only — no trades in window. Series mode
            returns 200 with `intervals: []` instead.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /vwap:
    get:
      tags: [price]
      summary: Volume-weighted average price over a window.
      description: |
        Returns VWAP = Σ(quote) / Σ(base) for every trade in
        [from, to) for the given pair. Optional outlier filter
        drops prices > N σ from the window mean. When the window
        has more than the server's per-request cap (10000 trades)
        the response carries `truncated: true` — the price
        reflects only the chronologically-first N trades, not the
        full window.
      parameters:
        - { $ref: "#/components/parameters/Base" }
        - { $ref: "#/components/parameters/QuoteRequired" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
        - name: outlier_sigma
          in: query
          schema: { type: number, minimum: 0 }
          description: "Drop trades > N σ from window mean. 0 disables (default)."
      responses:
        "200":
          description: Volume-weighted price + volumes + trade count.
          content:
            application/json:
              example:
                data:
                  from: '2026-07-03T21:37:30Z'
                  to: '2026-07-03T22:37:30Z'
                  price: '0.2045966459'
                  base_volume: '1385735379056'
                  quote_volume: '283516810732'
                  trade_count: 2483
                  outliers_filtered: 0
                  truncated: false
                as_of: '2026-07-03T22:37:41.757287184Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: true
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/VWAPEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: No trades in window.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422":
          description: All trades filtered as outliers.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /twap:
    get:
      tags: [price]
      summary: Time-weighted average price over a window.
      description: |
        Each trade's price is active from its timestamp to the
        next trade's (or windowEnd for the last). TWAP is the
        duration-weighted mean. No outlier filter: time-weighting
        is itself outlier-resistant (spurious prints get only
        their slot duration, not a full window's worth).
      parameters:
        - { $ref: "#/components/parameters/Base" }
        - { $ref: "#/components/parameters/QuoteRequired" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
      responses:
        "200":
          description: Time-weighted price + trade count.
          content:
            application/json:
              example:
                data:
                  from: '2026-07-03T21:37:30Z'
                  to: '2026-07-03T22:37:30Z'
                  price: '0.2044317708'
                  trade_count: 2483
                  truncated: false
                as_of: '2026-07-03T22:37:42.906364691Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: true
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/TWAPEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: No trades in window.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /oracle/latest:
    get:
      tags: [oracle]
      summary: Latest oracle reading per source for an asset.
      description: |
        Returns one OracleReading per source (reflector-dex /
        reflector-cex / reflector-fx / redstone / band /
        coingecko) that has observed the asset. Optional source
        filter restricts to a single source.

        Asset translation: classic Stellar identifiers map to the
        global crypto ticker the oracles publish under — so
        `asset=native` returns XLM observations (Reflector keys
        them as `crypto:XLM`), `asset=USDC-GA5Z…` returns the
        USDC global-ticker observations, etc. Already-canonical
        forms (`crypto:XLM`, `fiat:USD`, contract addresses) pass
        through unchanged.
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - name: source
          in: query
          schema: { type: string }
          description: "Optional. Restrict to a single source name."
      responses:
        "200":
          description: Array of OracleReading (empty when no observations).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OracleLatestEnvelope" }
              example:
                data:
                  - source: reflector-cex
                    contract_id: CAFJZQWSED6YAWZU3GWRTOCNPPCGBN32L7QV43XX5LZLFTK6JLN34DLN
                    asset: crypto:XLM
                    quote: fiat:USD
                    ts: "2026-05-05T16:25:00Z"
                    price: "0.15912"
                    price_raw: "15912000000000"
                    decimals: 14
                    confidence: 0.96
                    observer: GRELAYER0000000000000000000000000000000000000000000000000000
                  - source: band
                    asset: crypto:XLM
                    quote: fiat:USD
                    ts: "2026-05-05T16:25:30Z"
                    price: "0.15908"
                    price_raw: "159080000000000000"
                    decimals: 18
                  - source: redstone
                    asset: crypto:XLM
                    quote: fiat:USD
                    ts: "2026-05-05T16:24:00Z"
                    price: "0.15920"
                    price_raw: "159200000"
                    decimals: 9
                  - source: coingecko
                    asset: crypto:XLM
                    quote: fiat:USD
                    ts: "2026-05-05T16:25:00Z"
                    price: "0.15915"
                    price_raw: "15915"
                    decimals: 5
                as_of: "2026-05-05T16:25:42.881Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /pools:
    get:
      tags: [markets]
      summary: DEX/AMM liquidity pools (one row per (source, base, quote)).
      description: |
        Like /v1/markets but DEX-only and with a `source` dimension —
        the same pair traded on two DEXes appears as two rows. Backed
        by the dispatch path through Soroswap / Phoenix / Aquarius /
        SDEX / Comet (Class=Exchange + Subclass=DEX in the source
        registry). CEX trading pairs go through /v1/markets;
        "pool" is AMM/DEX terminology and applying it to centralised
        venues misnames the data.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - name: limit
          in: query
          description: Maximum rows per page (1-500, default 100).
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: order_by
          in: query
          schema: { type: string, enum: [volume_24h_usd_desc, pair], default: volume_24h_usd_desc }
        - name: source
          in: query
          schema: { type: string }
          description: |
            Optional. Restrict to a single DEX name (soroswap /
            phoenix / aquarius / sdex / comet). Non-DEX names return
            an empty list rather than 400.
        - name: base
          in: query
          schema: { type: string }
          description: |
            Optional. Canonical base asset_id (`native`, `USDC-G…`,
            etc.). Combined with `quote` gives the per-source
            breakdown for one pair — used by the pair detail page
            to render "which venues moved this pair in the last 24h".
        - name: quote
          in: query
          schema: { type: string }
          description: |
            Optional. Canonical quote asset_id. See `base`.
        - name: asset
          in: query
          schema: { type: string }
          description: |
            Optional. Canonical asset_id (`native`, `USDC-G…`,
            `fiat:USD`, …). Restricts to pools where the asset
            appears on either side (base OR quote). Use this on
            asset-detail surfaces to surface every pool touching
            the asset in one request, instead of firing parallel
            `?base=` + `?quote=` and merging client-side. Returns
            400 `invalid-asset-id` for unparseable values; 400
            `conflicting-filters` when combined with `base`/`quote`
            (the OR-shape and AND-shape filters can't be mixed).
      responses:
        "200":
          description: Array of pool rows.
          content:
            application/json:
              example:
                data:
                - source: sdex
                  base: native
                  quote: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  last_trade_at: '2026-07-03T21:59:52Z'
                  trade_count_24h: 74870
                  volume_24h_usd: '2151631.29284562791192465110403660600000000000000000'
                  last_price: '0.20545386626821068360'
                - source: aquarius
                  base: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                  quote: CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
                  last_trade_at: '2026-07-03T21:59:52Z'
                  trade_count_24h: 7831
                  volume_24h_usd: '1725605.79428767372866451771634747600000000000000000'
                  last_price: '4.8693065623459794'
                as_of: '2026-07-03T22:37:46.044283568Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
                pagination:
                  next: 1725605.79428767372866451771634747600000000000000000:aquarius|CCW67TSZV3SSS2HXMBQ5JFGCKJNX…
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PoolRow" }
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /lending/pools:
    get:
      tags: [protocols]
      summary: Lending pools (Blend) with auction + net-flow stats.
      description: |
        One row per distinct Blend pool contract observed in EITHER
        the auction stream OR the position-event stream, with 24h /
        all-time auction counts, 30d unique users, last-seen, plus a
        30-day net-flow proxy for supply/borrow.

        `net_supplied_30d` / `net_borrowed_30d` are window NET-FLOW
        deltas (token base-units, summed across the pool's assets),
        NOT all-time TVL or current reserve balances —
        `utilization_30d_pct` is the window borrow/supply ratio
        (omitted when net supply ≤ 0). Real current-state TVL +
        supply/borrow APYs (reserve b_rate/d_rate) need the Soroban
        pool-storage reader; these fields stand in until it ships.
      responses:
        "200":
          description: Array of LendingPool rows.
          content:
            application/json:
              example:
                data:
                - protocol: blend
                  pool: CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD
                  auctions_24h: 29
                  auctions_total: 7430
                  unique_users_30d: 10946
                  last_seen: '2026-07-03T22:37:27Z'
                  net_supplied_30d: '575363575586841'
                  net_borrowed_30d: '67357854119677'
                  utilization_30d_pct: 11.71
                as_of: '2026-07-03T22:37:47.445630311Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        protocol:            { type: string }
                        pool:                { type: string }
                        auctions_24h:        { type: integer, format: int64 }
                        auctions_total:      { type: integer, format: int64 }
                        unique_users_30d:    { type: integer, format: int64 }
                        last_seen:           { type: string, format: date-time }
                        net_supplied_30d:    { type: string, description: "Token base-units, 30d net-flow proxy (not TVL)." }
                        net_borrowed_30d:    { type: string, description: "Token base-units, 30d net-flow proxy." }
                        utilization_30d_pct: { type: number, nullable: true, description: "Window borrow/supply ratio %; null when net supply ≤ 0." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /lending/pools/{pool}/reserves:
    get:
      tags: [protocols]
      summary: Blend pool — REAL per-reserve current state (TVL/util/APY).
      description: |
        The pool's per-reserve CURRENT on-chain state (ADR-0039),
        decoded from the Blend pool contract's Soroban storage in the
        certified lake — distinct from the `/v1/lending/pools` window
        net-flow PROXY. For each reserve: supplied / borrowed amounts
        (`supplied` / `borrowed`, underlying token base units),
        utilization, and supply/borrow APR — all computed with the
        pool's own interest-rate model (b_rate/d_rate + the rate curve),
        matching the chain bit-for-bit.

        USD values (`supplied_usd` / `borrowed_usd`, and the pool
        `tvl_usd` = Σ supplied_usd) are BEST-EFFORT: present when we hold
        a USD price for the reserve's underlying token, null otherwise —
        the token-unit amounts + utilization + APR are always exact.
        Coverage = the live contract-storage capture window; a reserve
        with no captured entry is absent.
      parameters:
        - { name: pool, in: path, required: true, description: Pool contract C-strkey., schema: { type: string } }
      responses:
        "200":
          description: Per-reserve current state.
          content:
            application/json:
              example:
                data:
                  pool: CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD
                  tvl_usd: '189548299.92'
                  reserves:
                  - asset: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                    decimals: 7
                    supplied: '589262319356640'
                    borrowed: '442359640330233'
                    supplied_usd: '58926231.94'
                    borrowed_usd: '44235964.03'
                    utilization_pct: 75.07
                    borrow_apr: 0.1091
                    supply_apr: 0.0819
                  - asset: CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
                    decimals: 7
                    supplied: '6218654435342527'
                    borrowed: '12271342548905'
                    supplied_usd: '126950771.43'
                    borrowed_usd: '250513.42'
                    utilization_pct: 0.2
                    borrow_apr: 0.001
                    supply_apr: 0
                as_of: '2026-07-03T22:38:00.032774886Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      pool:    { type: string }
                      tvl_usd: { type: string, nullable: true, description: "Σ supplied_usd across priced reserves; null when none priced." }
                      reserves:
                        type: array
                        items:
                          type: object
                          properties:
                            asset:           { type: string, description: Reserve underlying token (C-strkey). }
                            decimals:        { type: integer }
                            supplied:        { type: string, description: Total supplied, underlying token base units. }
                            borrowed:        { type: string, description: Total borrowed, underlying token base units. }
                            supplied_usd:    { type: string, nullable: true }
                            borrowed_usd:    { type: string, nullable: true }
                            utilization_pct: { type: number, description: Borrowed/supplied, 0..100. }
                            borrow_apr:      { type: number, nullable: true, description: "Borrow APR as a fraction (0.05 = 5%). Null when the reserve's rate-model config isn't in the captured contract-storage window." }
                            supply_apr:      { type: number, nullable: true, description: "Supply APR as a fraction. Null when the rate-model config is uncaptured." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /mev:
    get:
      tags: [analytics]
      summary: Auto-flagged MEV-event feed (atomic arbitrage today).
      description: |
        The MEV detector's output, newest first. v1 flags ATOMIC
        ARBITRAGE: a single transaction in which one taker trades a
        closed asset cycle (≥2 legs returning to a starting asset)
        across pools/venues — the one pattern the served trade data
        supports unambiguously (rows carry tx_hash + taker + op_index
        but not intra-ledger transaction ordering, so cross-transaction
        sandwich detection would be guesswork).

        Each row's `detail` carries the evidence (assets, venues, the
        cyclic legs, and summed USD notional). `profit_usd` is null for
        arbitrage — v1 does not estimate attacker profit (leg direction
        is ambiguous in the served rows); see `detail.note`. The
        other kinds (sandwich / oracle_deviation / liquidation_cascade
        / wash_trade) are reserved for lake-backed signals not yet wired.

        200 + empty array when nothing's been detected or the reader
        isn't wired (feature-gated, like /v1/lending/pools).
      parameters:
        - { name: kind, in: query, description: "Filter to one pattern (e.g. arbitrage).", schema: { type: string, enum: [arbitrage, sandwich, oracle_deviation, liquidation_cascade, wash_trade] } }
        - { name: limit, in: query, description: "Maximum events to return (1-500, default 50).", schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
      responses:
        "200":
          description: MEV events, newest first.
          content:
            application/json:
              example:
                data:
                - event_id: bb8839de-2f7f-4bbe-8e61-8f01d20d0bfa
                  detected_at: '2026-07-03T22:32:57Z'
                  detected_at_ledger: 63316124
                  kind: arbitrage
                  tx_hashes:
                  - 5e0e2d198f2b61dfcf2ce7ccb102f9238f68deea627b2a978f5f8cc9c166bc14
                  accounts:
                  - GC6V7MSQ65LUM24ROBJZ4NROWTOSI3JUXOE2T7SNRHOYGK2OZMTNV7H7
                  detail:
                    legs:
                    - base: SCOP-GC6OYQJIZF3HFXCYPFCBXYXNGIBQ4TNSFUBUXQJOZWIP6F3YZK4QH3VQ
                      quote: native
                      source: sdex
                      op_index: 0
                      base_amount: '33238825'
                      quote_amount: '413237'
                    note: Atomic cyclic trade by one taker in a single transaction — an arbitrage signature. Detecti…
                    assets:
                    - SCOP-GC6OYQJIZF3HFXCYPFCBXYXNGIBQ4TNSFUBUXQJOZWIP6F3YZK4QH3VQ
                    sources:
                    - sdex
                    notional_usd: '0.05'
                  profit_usd: null
                as_of: '2026-07-03T22:38:01.17207501Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        event_id:           { type: string, format: uuid }
                        detected_at:        { type: string, format: date-time }
                        detected_at_ledger: { type: integer, format: int64 }
                        kind:               { type: string }
                        asset_id:           { type: string }
                        quote_id:           { type: string }
                        tx_hashes:          { type: array, items: { type: string } }
                        accounts:           { type: array, items: { type: string } }
                        detail:             { type: object, description: "Pattern evidence (arbitrage: assets/sources/legs/notional)." }
                        profit_usd:         { type: string, nullable: true, description: "Attacker-profit estimate; null for arbitrage (not estimated)." }
        "400": { $ref: "#/components/responses/BadRequest" }

  /anomalies:
    get:
      tags: [analytics]
      summary: Freeze-event timeline (ADR-0019).
      description: |
        The durable freeze-event mirror (`freeze_events`, ADR-0019):
        every clear→firing price-freeze transition, newest first, plus
        the live firing-now count and a per-reason tally over the
        window. While frozen, `/v1/price` still serves the last good
        value with `flags.frozen=true`; this endpoint is the history +
        current state of those decisions.

        `?firing=true` restricts the event list to currently-firing
        pairs; `?window_days=` scopes the reason tally (default 30);
        `?limit=` (default 100, max 500). 200 + empty payload when the
        reader isn't wired.
      parameters:
        - { name: firing, in: query, description: "true → only currently-firing events.", schema: { type: boolean } }
        - { name: window_days, in: query, description: "Trailing lookback in days for the freeze-event list and the per-reason tally (1-365, default 30).", schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
        - { name: limit, in: query, description: "Maximum rows to return (1-500, default 100). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Freeze timeline + firing count + reason tally.
          content:
            application/json:
              example:
                data:
                  firing_count: 0
                  reason_tally: []
                  events: []
                as_of: '2026-07-03T22:38:02.319192694Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      firing_count: { type: integer }
                      reason_tally:
                        type: array
                        items:
                          type: object
                          properties:
                            reason: { type: string }
                            count:  { type: integer, format: int64 }
                      events:
                        type: array
                        items:
                          type: object
                          properties:
                            asset_id:            { type: string }
                            quote_id:            { type: string }
                            frozen_at:           { type: string, format: date-time }
                            frozen_at_ledger:    { type: integer, format: int64 }
                            reason:              { type: string, enum: [single_source, divergence, outlier_storm, manual] }
                            frozen_value:        { type: string }
                            recovered_at:        { type: string, format: date-time, nullable: true }
                            recovered_at_ledger: { type: integer, format: int64, nullable: true }
                            firing:              { type: boolean }
                            detail:              { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }

  /divergence:
    get:
      tags: [analytics]
      summary: Cross-reference divergence board (ADR-0019).
      description: |
        The current divergence board: the latest comparison per
        (asset, quote, reference) over the trailing window, from
        `divergence_observations`. Each row is our VWAP vs one external
        reference (CoinGecko / Chainlink / Reflector DEX·CEX·FX /
        Redstone / Band) with `delta_pct = (our − ref) / ref × 100`.
        Ordered widest |delta_pct| first.

        A row with `status: firing` breached its per-(reference, pair)
        threshold at its latest observation — the signal behind
        `flags.divergence_warning`. `?firing=true` restricts to those;
        `?window_days=` (default 7); `?limit=` (default 100, max 500).
        200 + empty payload when the reader isn't wired.
      parameters:
        - { name: firing, in: query, description: "true → only rows whose latest status is firing.", schema: { type: boolean } }
        - { name: window_days, in: query, description: "Trailing lookback in days for divergence rows (1-365, default 7).", schema: { type: integer, minimum: 1, maximum: 365, default: 7 } }
        - { name: limit, in: query, description: "Maximum rows to return (1-500, default 100). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Latest divergence per (pair, reference).
          content:
            application/json:
              example:
                data:
                  observations:
                  - asset_id: crypto:BTC
                    quote_id: fiat:USD
                    reference: chainlink
                    observed_at: '2026-07-03T22:37:08.896016Z'
                    observed_at_ledger: 0
                    our_price: '62543.07358731602'
                    ref_price: '62608.75585288'
                    delta_pct: '-0.10490907329051442'
                    status: clear
                  - asset_id: crypto:ETH
                    quote_id: fiat:USD
                    reference: coingecko
                    observed_at: '2026-07-03T22:37:08.90697Z'
                    observed_at_ledger: 0
                    our_price: '1757.84660921192'
                    ref_price: '1756.21'
                    delta_pct: '0.09318983560735354'
                    status: clear
                as_of: '2026-07-03T22:38:03.478937272Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      observations:
                        type: array
                        items:
                          type: object
                          properties:
                            asset_id:           { type: string }
                            quote_id:           { type: string }
                            reference:          { type: string, enum: [chainlink, coingecko, reflector-cex, reflector-fx, reflector-dex, redstone, band] }
                            observed_at:        { type: string, format: date-time }
                            observed_at_ledger: { type: integer, format: int64 }
                            our_price:          { type: string }
                            ref_price:          { type: string }
                            delta_pct:          { type: string }
                            status:             { type: string, enum: [clear, firing] }
        "400": { $ref: "#/components/responses/BadRequest" }

  /oracle/streams:
    get:
      tags: [oracle]
      summary: Latest observation per (oracle, asset, quote) — 7d window.
      description: |
        Returns one row per distinct (source, asset, quote) triple
        in the trailing 7 days. Backs the explorer's /oracles
        price-streams table. Sources with no observation in the
        window are absent from the result.
      responses:
        "200":
          description: Array of OracleReading.
          content:
            application/json:
              example:
                data:
                - source: band
                  contract_id: CCQXWMZVM3KRTXTUPTN53YHL272QGKF32L7XEDNZ2S6OSUFK3NFBGG5M
                  asset: crypto:USDC
                  quote: fiat:USD
                  ts: '2026-07-03T22:06:18Z'
                  price: '0.999879000'
                  price_raw: '999879000'
                  decimals: 9
                  observer: GCNTSKF3QBZJHS5JTD72TI35QP2PLMCKFMFNPXJI2YCQXYBUJLRHFCZX
                as_of: '2026-07-03T22:38:05.023074622Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/OracleLatestEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /markets:
    get:
      tags: [markets]
      summary: Distinct trading pairs with activity summary.
      description: |
        One entry per (base, quote) pair that has traded in the
        recency window (default 14 days). Cursor-paginated (opaque
        cursor keyed on `<base>|<quote>`). Each entry reports the
        pair's most recent trade timestamp + a 24h trade count.

        The recency window keeps the listing scoped to "active
        markets" — pairs that haven't traded in 14 days don't
        appear. This also bounds the underlying scan against the
        trades hypertable so chunk pruning keeps response times
        sub-second on a hypertable with hundreds of millions of
        rows.
      parameters:
        - { $ref: "#/components/parameters/Cursor" }
        - name: limit
          in: query
          description: Maximum rows per page (1-500, default 100).
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: order_by
          in: query
          required: false
          description: |
            Sort order. `volume_24h_usd_desc` (default) orders by
            24h USD volume desc (NULLS LAST), then by
            `<base>|<quote>` for ties — surfaces the high-activity
            pairs first without paginating through ~5K alphabetic
            dust pairs.

            `pair` returns markets in lex order of
            `<base>|<quote>` — stable for paginating the full set,
            but surfaces spam-token pairs (`0-…`, `0TAX-…`) at the
            top of the listing. Pre-2026-05-10 this was the
            default; we kept it as an explicit option so callers
            paginating the entire universe of pairs aren't broken.

            Cursor format differs per ordering; keep using the
            cursor returned by the previous response.
          schema:
            type: string
            enum: [pair, volume_24h_usd_desc]
            default: volume_24h_usd_desc
        - name: source
          in: query
          required: false
          description: |
            Restrict the listing to markets a single source
            observed in the recency window. Must match a
            registered source name (see `/v1/sources`); an
            unknown name returns 400 `unknown-source` rather
            than an empty 200 (avoids the silent-empty-page
            anti-pattern). Mutually exclusive with `asset`.
          schema: { type: string }
        - name: asset
          in: query
          required: false
          description: |
            Restrict the listing to markets where the given
            canonical `asset_id` appears on either side (base
            OR quote). Use this on asset-detail surfaces to
            surface every market an asset participates in
            without paying for a global scan + client-side
            filter. Returns 400 `invalid-asset-id` if the
            value isn't a parseable canonical asset_id (e.g.
            `native`, `USDC-G…`, `fiat:USD`). Mutually
            exclusive with `source`.
          schema: { type: string }
      responses:
        "200":
          description: Array of markets + optional next cursor.
          content:
            application/json:
              example:
                data:
                - base: crypto:BTC
                  quote: crypto:USDT
                  last_trade_at: '2026-07-03T22:36:00Z'
                  bucket_close_at: '2026-07-03T00:00:00Z'
                  trade_count_24h: 667311
                  volume_24h_usd: '845925256.01203780'
                  last_price: '61977.170000000000'
                - base: crypto:BTC
                  quote: fiat:USD
                  last_trade_at: '2026-07-03T22:36:00Z'
                  bucket_close_at: '2026-07-03T00:00:00Z'
                  trade_count_24h: 636711
                  volume_24h_usd: '500463722.75372090'
                  last_price: '61901.333333333333'
                as_of: '2026-07-03T22:38:06.818142585Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
                pagination:
                  next: 500463722.75372090:crypto:BTC|fiat:USD
              schema: { $ref: "#/components/schemas/MarketsEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /markets/sources:
    get:
      tags: [markets]
      summary: Per-source 24h volume breakdown for a pair or asset.
      description: |
        Trailing-24h USD volume + trade count grouped by source, for
        either a single market pair (`base` + `quote`) or an asset
        across every pair it appears in (`asset`). Backs the
        volume-by-source breakdown (pie) on the market-pair + asset
        pages — the `/v1/history` feed only samples recent trades, so
        an accurate 24h share needs this server-side aggregate.

        Volume derivation matches `/v1/sources?include=stats` (the
        XLM/USD fallback for native / XLM-SAC legs); a source whose
        trades carry no derivable USD volume still appears with its
        trade count and a null `volume_24h_usd`. `share_pct` is the
        source's share of the total derivable USD volume across all
        sources (0 when the total is unknown).

        Pass EITHER `base`+`quote` OR `asset` — combining them is a
        400.
      parameters:
        - name: base
          in: query
          required: false
          description: Canonical base asset_id (with `quote`, for a single pair).
          schema: { type: string }
        - name: quote
          in: query
          required: false
          description: Canonical quote asset_id (with `base`, for a single pair).
          schema: { type: string }
        - name: asset
          in: query
          required: false
          description: |
            Canonical asset_id; aggregates every pair the asset appears
            in (base or quote side). Mutually exclusive with `base`/`quote`.
          schema: { type: string }
      responses:
        "200":
          description: Per-source breakdown (standard envelope).
          content:
            application/json:
              example:
                data:
                  base: native
                  quote: fiat:USD
                  window_secs: 86400
                  sources: []
                as_of: '2026-07-03T22:38:07.960705714Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [window_secs, sources]
                        properties:
                          base: { type: string }
                          quote: { type: string }
                          asset: { type: string }
                          window_secs:
                            type: integer
                            description: Aggregation window in seconds (86400 = trailing 24h).
                          sources:
                            type: array
                            items:
                              type: object
                              required: [source, trade_count_24h, share_pct]
                              properties:
                                source:
                                  type: string
                                  description: Source name (see /v1/sources).
                                volume_24h_usd:
                                  type: string
                                  nullable: true
                                  description: SUM derivable USD volume over 24h. Decimal string per ADR-0003.
                                trade_count_24h:
                                  type: integer
                                share_pct:
                                  type: number
                                  description: Share of total derivable USD volume across sources (%).
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /issuers:
    get:
      tags: [meta]
      summary: Issuer directory ranked by total observation count.
      description: |
        Lists every issuer (G-account that has minted at least one
        classic asset) ordered by total observation count across
        their issued assets. The home_domain column is empty until
        the SEP-1 fetcher worker resolves it (Phase 4).

        Powers the showcase `/issuers` directory page.
      parameters:
        - name: limit
          in: query
          required: false
          description: Max rows to return; 1-500, default 100.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Array of issuer summaries (standard envelope; data is the array).
          content:
            application/json:
              example:
                data:
                - g_strkey: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  home_domain: circle.com
                  org_name: Centre Consortium LLC
                  org_verified: true
                  asset_count: 1
                  total_observation_count: 41639649
                - g_strkey: GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55
                  home_domain: ultracapital.xyz
                  org_name: Ultra Capital LLC
                  org_verified: true
                  asset_count: 1
                  total_observation_count: 32433641
                as_of: '2026-07-03T22:38:09.267785261Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          required:
                            - g_strkey
                            - asset_count
                            - total_observation_count
                          properties:
                            g_strkey: { type: string }
                            home_domain: { type: string }
                            org_name:
                              type: string
                              description: |
                                Issuer's organisation name from SEP-1
                                `[DOCUMENTATION].ORG_NAME`. Populated by
                                the `stellarindex-ops sep1-refresh` job;
                                empty until the issuer's stellar.toml
                                has been resolved.
                            asset_count: { type: integer, format: int64 }
                            total_observation_count: { type: integer, format: int64 }
                            org_verified:
                              type: boolean
                              description: |
                                True only when SEP-1 verification is
                                bidirectional (the issuer's toml lists this
                                issuer back — CS-100). When false, org_name
                                is unverified self-declared metadata.
                            scam_reason:
                              type: string
                              description: "Non-empty when this issuer is in the curated scam directory (issuers.go). Render as a warning."
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /issuers/{g_strkey}:
    get:
      tags: [meta]
      summary: Issuer detail — auth flags, SEP-1 metadata, issued assets.
      description: |
        Returns the row from the `issuers` table joined with the
        list of every classic asset this G-account has ever issued.
        Auth flags + SEP-1 payload land per-issuer as the SEP-1
        fetcher worker resolves them (Phase 4); fields stay null
        until then.

        Powers the explorer's `/issuers/{g_strkey}` detail page.
      parameters:
        - name: g_strkey
          in: path
          required: true
          description: |
            Issuer account id — 56-character G-strkey (SEP-23),
            e.g. `GA5Z…KZVN`. Malformed strkeys return 400;
            well-formed accounts that have never issued an
            observed asset return 404.
          schema:
            type: string
            example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
          example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
      responses:
        "200":
          description: One issuer row plus issued-asset list (standard envelope).
          content:
            application/json:
              example:
                data:
                  g_strkey: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  home_domain: circle.com
                  org_name: Centre Consortium LLC
                  org_verified: true
                  auth_required: false
                  auth_revocable: true
                  auth_immutable: false
                  auth_clawback: false
                  sep1_resolved_at: '2026-07-03T14:56:36Z'
                  sep1_payload:
                    OrgName: Centre Consortium LLC
                    Currencies:
                    - Code: USDC
                      Name: USD Coin
                      Issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                      AnchorAsset: USD
                      Description: USDC is a fully collateralized US Dollar stablecoin, based on the open source fiat stablec…
                      AnchorAssetType: fiat
                    OrgVerified: true
                    Documentation:
                      ORG_DBA: Centre Consortium
                      ORG_URL: https://www.centre.io
                      ORG_NAME: Centre Consortium LLC
                  creation_ledger: 34180766
                  assets:
                  - asset_id: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                    code: USDC
                    slug: USDC
                    first_seen_ledger: 34180766
                    last_seen_ledger: 63316034
                    observation_count: 41639649
                as_of: '2026-07-03T22:38:13.056025402Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [g_strkey]
                        properties:
                          g_strkey: { type: string }
                          home_domain: { type: string }
                          org_name:
                            type: string
                            description: |
                              Issuer's organisation name from SEP-1
                              `[DOCUMENTATION].ORG_NAME`. Same value the
                              listing endpoint surfaces. SELF-DECLARED unless
                              `org_verified` is true — do NOT render as
                              authoritative without checking `org_verified`.
                          scam_reason:
                            type: string
                            description: "Non-empty when this issuer is in the curated scam directory (issuers.go). Render as a warning."
                          org_verified:
                            type: boolean
                            description: |
                              True only when the issuer's SEP-1 toml lists this
                              issuer back (bidirectional proof; one-way is
                              spoofable). When false, `org_name` is unverified
                              self-declared metadata — clients must present it
                              as such, not as a verified identity (CS-100).
                          auth_required: { type: boolean, nullable: true }
                          auth_revocable: { type: boolean, nullable: true }
                          auth_immutable: { type: boolean, nullable: true }
                          auth_clawback: { type: boolean, nullable: true }
                          sep1_resolved_at: { type: string, format: date-time, nullable: true }
                          sep1_payload: { type: object, additionalProperties: true, nullable: true }
                          creation_ledger: { type: integer, nullable: true }
                          assets:
                            type: array
                            items:
                              type: object
                              properties:
                                asset_id: { type: string }
                                code: { type: string }
                                slug: { type: string }
                                first_seen_ledger: { type: integer }
                                last_seen_ledger: { type: integer }
                                observation_count: { type: integer, format: int64 }
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts/{contract_id}/transfers:
    get:
      tags: [explorer]
      summary: Per-contract SEP-41 transfer audit trail.
      description: |
        Returns the most-recent N (default 100, max 500)
        audit-trail rows for the given SEP-41 token contract,
        ordered newest-first. Includes every `transfer` /
        `approve` / `set_admin` / `set_authorized` event the
        decoder observed; `mint` / `burn` / `clawback` live
        separately in supply analytics.

        Powers the per-account net-position queries that
        CoinGecko / CoinMarketCap structurally cannot offer —
        they only see exchange-side flows, not on-chain
        transfers.

        F-0021 closure (audit-2026-05-26).
      parameters:
        - name: contract_id
          in: path
          required: true
          description: SEP-41 token contract C-strkey.
          schema:
            type: string
            example: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
        - name: from
          in: query
          required: false
          description: |
            Filter to events where this G/C-strkey was the
            sender (transfer.from, approve.from).
          schema:
            type: string
        - name: to
          in: query
          required: false
          description: |
            Filter to events where this G/C-strkey was the
            recipient (transfer.to, approve.spender,
            set_admin.new_admin, set_authorized.id).
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Maximum audit-trail rows to return (1-500, default 100).
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Per-contract audit-trail rows, newest-first (standard envelope).
          content:
            application/json:
              example:
                data:
                  contract_id: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                  count: 2
                  limit: 2
                  transfers:
                  - ledger: 62757524
                    ledger_close_time: '2026-05-27T09:41:47Z'
                    tx_hash: f35ce4e2b091debd5992cf3490a8701400e5655b033c425569eb2abbe19cb791
                    op_index: 1
                    event_index: 0
                    event_kind: transfer
                    from: GBK6ITJCG4QPOAJFFWMKEICDAMWFSEACFO654R4E2LDH77XYS76DPYXR
                    to: GCAQSQVXUJZPDND4EUWQYRCJ64IGQ3REQK2CVSXHUQQ26GCTEMIGJDSC
                    amount: '40700000'
                  - ledger: 62757524
                    ledger_close_time: '2026-05-27T09:41:47Z'
                    tx_hash: 6517426650acea35cf0cd7f2ba420a4c7ec9619adffefce86c3ef015df618688
                    op_index: 1
                    event_index: 0
                    event_kind: transfer
                    from: GAUA7XL5K54CC2DDGP77FJ2YBHRJLT36CPZDXWPM6MP7MANOGG77PNJU
                    to: GCAQSQVXUJZPDND4EUWQYRCJ64IGQ3REQK2CVSXHUQQ26GCTEMIGJDSC
                    amount: '3600000'
                as_of: '2026-07-03T22:38:15.931276977Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [contract_id, count, limit, transfers]
                        properties:
                          contract_id: { type: string }
                          count: { type: integer }
                          limit: { type: integer }
                          from: { type: string }
                          to: { type: string }
                          transfers:
                            type: array
                            items:
                              type: object
                              required: [ledger, ledger_close_time, event_kind]
                              properties:
                                ledger: { type: integer }
                                ledger_close_time: { type: string, format: date-time }
                                tx_hash: { type: string }
                                op_index: { type: integer }
                                event_index: { type: integer }
                                event_kind:
                                  type: string
                                  enum: [transfer, approve, set_admin, set_authorized]
                                from: { type: string }
                                to: { type: string }
                                amount:
                                  type: string
                                  description: |
                                    i128 amount as a decimal string (ADR-0003).
                                    Populated for transfer + approve; omitted
                                    for set_admin + set_authorized.
                                live_until_ledger:
                                  type: integer
                                  description: approve.live_until_ledger.
                                authorized:
                                  type: boolean
                                  description: set_authorized.authorize.
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /changes/{entity_type}/{id}:
    get:
      tags: [meta]
      summary: Multi-window delta strip for one entity.
      description: |
        Returns the pre-computed multi-window delta strip + ATH/ATL +
        streak + acceleration for one (entity_type, id) tuple. Powers
        every list view + price card on the showcase site
        (data-inventory §6.1). Refreshed every 5 minutes by the
        change-summary worker; stale rows (>10 min) indicate the
        worker is lagging.

        Returns 404 when the worker hasn't computed a row yet (fresh
        deployment, newly-added entity, or bounded history). Returns
        503 when this deployment hasn't wired the change-summary
        reader.
      parameters:
        - name: entity_type
          in: path
          required: true
          description: |
            Which entity family the delta strip is computed over:
            `coin` (an asset's price/volume deltas), `protocol`
            (per-protocol activity), `pair` (a trading pair), or
            `source` (an ingest source). Determines how `{id}` is
            interpreted — see the `id` parameter.
          schema:
            type: string
            enum: [coin, protocol, pair, source]
            example: source
          example: source
        - name: id
          in: path
          required: true
          description: |
            Canonical id for the entity. Form depends on
            `entity_type`:

            - `coin`: any of the asset's identifier forms — friendly
              slug (`XLM`, `USDC`), canonical asset_id (`native`,
              `crypto:XLM`, `USDC-GA5Z…`), or bare classic code
              (`USDC` → also tries `crypto:USDC`). The handler
              expands the input into every candidate the
              change-summary worker might have keyed under and
              returns the first hit. Without expansion, a typo of
              the canonical form would 404 even when data is
              populated under a sibling form.
            - `pair`: `base/quote` form (e.g. `native/USDC-GA5Z…`).
            - `protocol`: protocol slug (e.g. `soroswap`, `blend`).
            - `source`: source name (e.g. `binance`, `coinbase`,
              `sdex`).
          schema:
            type: string
            example: binance
          example: binance
      responses:
        "200":
          description: One row from change_summary_5m (standard envelope).
          content:
            application/json:
              example:
                data:
                  entity_type: coin
                  entity_id: crypto:XLM
                  refreshed_at: '2026-07-03T22:38:00Z'
                  current_value: 0.2041764538697883
                  h1_value: 0.20380247911865504
                  h1_delta_pct: 0.1834986270778068
                  h24_value: 0.19673099518995452
                  h24_delta_pct: 3.784588530467602
                  d7_value: 0.17768054591385554
                  d7_delta_pct: 14.912104090888326
                  d30_value: 0.21078647159614772
                  d30_delta_pct: -3.13588328335594
                  ath_value: 0.29758550057923283
                  ath_at: '2026-05-30T03:52:00Z'
                  atl_value: 0.13999047864054645
                  atl_at: '2026-05-23T08:41:00Z'
                  streak_direction: up
                  streak_days: 5
                  acceleration: decreasing
                as_of: '2026-07-03T22:40:39.873451257Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          entity_type: { type: string }
                          entity_id: { type: string }
                          refreshed_at: { type: string, format: date-time }
                          current_value: { type: number }
                          h1_value: { type: number, nullable: true }
                          h1_delta_pct: { type: number, nullable: true }
                          h24_value: { type: number, nullable: true }
                          h24_delta_pct: { type: number, nullable: true }
                          d7_value: { type: number, nullable: true }
                          d7_delta_pct: { type: number, nullable: true }
                          d30_value: { type: number, nullable: true }
                          d30_delta_pct: { type: number, nullable: true }
                          ath_value: { type: number, nullable: true }
                          ath_at: { type: string, format: date-time, nullable: true }
                          atl_value: { type: number, nullable: true }
                          atl_at: { type: string, format: date-time, nullable: true }
                          streak_direction:
                            type: string
                            enum: [up, down, flat]
                            nullable: true
                          streak_days: { type: integer, nullable: true }
                          acceleration:
                            type: string
                            enum: [increasing, flat, decreasing]
                            nullable: true
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /diagnostics/cursors:
    get:
      tags: [meta]
      summary: Per-source ingest cursor positions.
      description: |
        Returns every row of the `ingestion_cursors` table — the
        per-source markers the dispatcher persists after each ledger
        is processed. Used for operator-facing diagnostics and the
        showcase /diagnostics page (so you can see at a glance which
        sources are caught up, which are lagging, and how stale the
        last update is).

        Sources that track multiple positions independently (e.g.
        Soroswap tracks the factory cursor + one cursor per pair)
        return one row per (source, sub_source) tuple.

        Pass `?max_age=<duration>` to omit completed-backfill
        cursors that drown out the live ledgerstream marker —
        useful when polling from monitoring tools that can't
        post-filter. The explorer's `/diagnostics` page applies
        the same filter client-side, defaulting to 1h.

        Pass `?source=<name>` for an exact-match filter on the
        `source` column — typical values are `ledgerstream` (the
        live indexer) and `backfill` (one row per backfill
        range). Composes with `?max_age=` (both filters apply).

        Returns 503 when the deployment hasn't wired the cursors
        reader.
      parameters:
        - name: status
          in: query
          required: false
          description: |
            Semantic convenience filter (R-015). Values:
              - `active` — only rows with `lag_seconds <= 600` (10 min).
                Excludes completed backfill cursors that linger in the
                table after their range finished.
              - `stale`  — complement; only rows older than the 10-min
                boundary. Useful for spotting dead ingest paths.
              - omitted — return everything (subject to `max_age` + `source`).
            Composes with `max_age`: for `status=active` the effective
            window is whichever bound is tighter; for `status=stale` the
            window becomes `[10m, max_age]`.
          schema:
            type: string
            enum: [active, stale]
            example: "active"
        - name: max_age
          in: query
          required: false
          description: |
            Positive Go-duration string (e.g. `1h`, `30m`, `5m`,
            `0.5h`). When present, rows whose `lag_seconds`
            exceeds this value are excluded from the response.
            Empty / omitted preserves the legacy "return every
            cursor" contract.
          schema:
            type: string
            example: "1h"
        - name: source
          in: query
          required: false
          description: |
            Exact-match filter on the `source` column. Typical
            values: `ledgerstream` (the live indexer) or
            `backfill` (one row per backfill range). Unknown
            values return an empty array (not 400) — keeps the
            surface predictable when an operator typos vs. a
            brand-new source we haven't seen yet.
          schema:
            type: string
            example: "ledgerstream"
      responses:
        "200":
          description: Array of cursor entries, one per (source, sub_source) (standard envelope; data is the array).
          content:
            application/json:
              example:
                data:
                - source: backfill
                  sub_source: 11474999-15299997:sdex
                  last_ledger: 15299997
                  last_updated: '2026-05-14T18:19:34Z'
                  lag_seconds: 4335523
                as_of: '2026-07-03T22:38:18.218056301Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          required:
                            - source
                            - last_ledger
                            - last_updated
                            - lag_seconds
                          properties:
                            source: { type: string }
                            sub_source: { type: string }
                            last_ledger: { type: integer }
                            last_updated: { type: string, format: date-time }
                            lag_seconds: { type: integer, format: int64 }
                    required: [data]
        "400":
          description: |
            Either `max_age` didn't parse as a positive Go duration
            (`type=https://api.stellarindex.io/errors/invalid-max-age`),
            or `status` was set to a value other than `active` /
            `stale` (`type=https://api.stellarindex.io/errors/invalid-status`).
            Body is the standard problem+json envelope.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /diagnostics/ingestion:
    get:
      tags: [meta]
      summary: Per-region ingestion snapshot — ledger tip, backfill, FX, supply, sources.
      description: |
        One snapshot of the region's ingest state, composed from
        the cursors table, network_stats CAGG, fx_quotes hypertable,
        asset_supply_history, the in-memory market-cap cache, and
        the static source registry. Designed as the single fetch
        the public status page makes for its "Ingestion · <region>"
        panel — so the page renders the whole panel without scraping
        five separate endpoints.

        Sections:
          - `region`         — name + deployment (r1/production today).
          - `version`        — what binary is running here.
          - `ledger`         — live tip, lag, 24h volume, indexed assets/markets.
          - `backfill`       — per-decoder ranges in progress, oldest lag.
          - `fx_backfill`    — fx_quotes coverage (earliest/latest, total quotes).
          - `market_cap`     — CoinGecko cache age + entries.
          - `supply`         — counts per asset domain + most recent observation.
          - `sources`        — every source in the registry joined with
                               its trailing-24h trades/volume/markets.

        Cache: `public, max-age=15`. The underlying readers change
        on second-scale (cursor updates, supply ticks); 15s smooths
        a refreshing status page without hiding degradation.
      responses:
        "200":
          description: Snapshot of the region's ingest state (standard envelope).
          content:
            application/json:
              example:
                data:
                  region:
                    name: r1
                    deployment: production
                  version:
                    version: v0.7.6
                    build_date: '2026-07-03T22:24:02Z'
                    commit: 3d26b9d2b1bf72dc3caec9db9dceff59ca24f6b7
                  ledger:
                    latest_ledger: 63316172
                    lag_seconds: 0
                    volume_24h_usd: '2904068272.78168669'
                    markets_count_24h: 27488
                    assets_indexed: 191015
                  backfill:
                  - decoder: aquarius
                    ranges_total: 1
                    ranges_complete: 1
                    newest_ledger: 62637704
                  backfill_coverage:
                  - source: aquarius
                    applies: true
                    coverage_pct: 1
                    completeness_pct: 1
                    completeness_complete: true
                  backfill_coverage_as_of: '2026-07-03T22:38:12Z'
                  cagg_coverage:
                    earliest_bucket: '2015-11-18T03:00:00Z'
                    latest_bucket: '2026-07-03T21:00:00Z'
                    bucket_count: 175011581
                  fx_backfill:
                    earliest_quote: '2001-05-11'
                    latest_quote: '2026-07-03'
                    total_quotes: 244477
                    currencies_count: 132
                  supply:
                    classic_assets_with_supply: 9
                    sep41_assets_with_supply: 0
                    last_snapshot_at: '2026-07-03T22:38:02Z'
                    latest_ledger: 63316175
                  sources:
                  - name: aquarius
                    class: exchange
                    include_in_vwap: true
                    trade_count_24h: 12946
                    volume_24h_usd: '2100883.02'
                as_of: '2026-07-03T22:38:19.403102451Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        description: "Operator diagnostics — the documented properties are the stable core; the handler also serves per-source coverage fields (density_pct, gap_free_pct, covered_ledgers, coverage_snapshot_at, entries_24h) and ADR-0033 completeness fields that evolve with the pipeline (board #33; x-stability: experimental per ADR-0042)."
                        required: [region, version, ledger, backfill, backfill_coverage, fx_backfill, market_cap, supply, sources]
                        properties:
                          region:
                            type: object
                            required: [name, deployment]
                            properties:
                              name:       { type: string, example: "r1" }
                              deployment: { type: string, example: "production" }
                          version:
                            type: object
                            required: [version, build_date, commit, dirty, go_version]
                            properties:
                              version:    { type: string, example: "v0.5.0-rc.50" }
                              build_date: { type: string, example: "2026-05-13T18:21:59Z" }
                              commit:     { type: string, example: "539333827c1a25d3ecfdcda67aa9c9a6f30cf6d3" }
                              dirty:      { type: string, example: "false" }
                              go_version: { type: string, example: "go1.25.10" }
                          ledger:
                            type: object
                            required: [latest_ledger, lag_seconds, markets_count_24h, assets_indexed]
                            properties:
                              latest_ledger:     { type: integer, format: int64 }
                              lag_seconds:       { type: integer, format: int64 }
                              volume_24h_usd:    { type: string, description: "Decimal string per ADR-0003." }
                              markets_count_24h: { type: integer, format: int64 }
                              assets_indexed:    { type: integer, format: int64 }
                          backfill:
                            type: array
                            items:
                              type: object
                              required: [decoder, ranges_total, ranges_complete, ranges_running, ranges_stalled, ranges_active, oldest_lag_seconds, newest_ledger]
                              properties:
                                decoder:            { type: string, example: "sdex,soroswap" }
                                ranges_total:       { type: integer, description: "Total cursor rows for this decoder set." }
                                ranges_complete:    { type: integer, description: "last_ledger == range_end." }
                                ranges_running:     { type: integer, description: "Incomplete AND updated within the last 10 min — actively progressing." }
                                ranges_stalled:     { type: integer, description: "Incomplete AND not updated for 10+ min — needs `-resume` restart." }
                                ranges_active:      { type: integer, description: "Back-compat: ranges_running + ranges_stalled." }
                                oldest_updated_at:  { type: string, format: date-time }
                                oldest_lag_seconds: { type: integer, format: int64 }
                                newest_ledger:      { type: integer, format: int64 }
                          backfill_coverage:
                            type: array
                            description: |
                              Per-source min/max ledger + trade count from the
                              trades hypertable. Answers "do we have data
                              from genesis to tip?" — `applies=true` rows are
                              Stellar-ledger-bearing sources (sdex + Soroban
                              contracts); CEX/FX sources surface as
                              `applies=false`. Background-refreshed every
                              5 min; empty array until first refresh
                              completes after process start.
                            items:
                              type: object
                              required: [source, applies, entries]
                              properties:
                                source:          { type: string, example: "sdex" }
                                applies:         { type: boolean, description: "False for CEX/FX sources whose trades have no Stellar ledger." }
                                genesis_ledger:  { type: integer, format: int64, description: "Operator-curated source genesis (1 for SDEX, contract deploy ledger for Soroban)." }
                                earliest_ledger: { type: integer, format: int64 }
                                latest_ledger:   { type: integer, format: int64 }
                                entries:         { type: integer, format: int64, description: "Always-on per-source ingested-entry tally — trades for exchange/DEX/CEX sources, oracle_updates for oracle sources (source_entry_counts, migration 0035). Exact even mid-backfill; renamed from trade_count 2026-05-15." }
                                coverage_pct:    { type: number,  description: "Fraction of (genesis → tip) range with any data. 1.0 = covered. Doesn't detect internal gaps." }
                          backfill_coverage_as_of:
                            type: string
                            format: date-time
                            description: When the backfill_coverage snapshot was last refreshed.
                          cagg_coverage:
                            type: object
                            description: |
                              MIN/MAX bucket of `prices_1h`, the canonical
                              "long-lived" continuous aggregate. Real
                              source-of-truth for "do we have historical
                              OHLC since genesis?" — raw trades have a 90-day
                              retention but the hourly+ CAGGs are retained
                              forever (migration 0002).
                            required: [bucket_count]
                            properties:
                              earliest_bucket: { type: string, format: date-time }
                              latest_bucket:   { type: string, format: date-time }
                              bucket_count:    { type: integer, format: int64 }
                          fx_backfill:
                            type: object
                            required: [total_quotes, currencies_count]
                            properties:
                              earliest_quote:   { type: string, example: "1999-01-04" }
                              latest_quote:     { type: string, example: "2026-05-13" }
                              total_quotes:     { type: integer, format: int64 }
                              currencies_count: { type: integer }
                          market_cap:
                            type: object
                            required: [entries_count]
                            properties:
                              entries_count:     { type: integer }
                              oldest_fetched_at: { type: string, format: date-time }
                              newest_fetched_at: { type: string, format: date-time }
                          supply:
                            type: object
                            required: [classic_assets_with_supply, sep41_assets_with_supply]
                            properties:
                              classic_assets_with_supply: { type: integer }
                              sep41_assets_with_supply:   { type: integer }
                              last_snapshot_at:           { type: string, format: date-time }
                              latest_ledger:              { type: integer, format: int64 }
                          sources:
                            type: array
                            items:
                              type: object
                              required: [name, class, include_in_vwap, backfill_safe, trade_count_24h, markets_count_24h]
                              properties:
                                name:              { type: string, example: "binance" }
                                class:             { type: string, example: "exchange" }
                                subclass:          { type: string, example: "cex" }
                                include_in_vwap:   { type: boolean }
                                backfill_safe:     { type: boolean }
                                trade_count_24h:   { type: integer, format: int64 }
                                volume_24h_usd:    { type: string }
                                markets_count_24h: { type: integer, format: int64 }
                    required: [data]
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /incidents:
    get:
      tags: [meta]
      summary: Customer-facing incident posts.
      description: |
        Returns every customer-facing incident post the binary has
        embedded (`internal/incidents/data/*.md`), parsed at startup
        from YAML frontmatter + markdown body. Sorted by
        `started_at` descending (most recent first).

        Posts are added to the binary at build time — there is no
        runtime write path. Files starting with `_` (templates) are
        skipped at parse time. New incidents ship with a redeploy.

        Powers the "Incident history" section on
        status.stellarindex.io. Distinct from `/v1/status` which
        reports the *currently active* incidents from Alertmanager.
      responses:
        "200":
          description: List of past incidents, newest first (standard envelope).
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required:
                          - incidents
                          - count
                        properties:
                          incidents:
                            type: array
                            items:
                              type: object
                              required: [slug, title, severity, status, started_at, body_markdown]
                              properties:
                                slug: { type: string, example: "2026-05-06-postgres-lock-table-full" }
                                title: { type: string }
                                severity:
                                  type: string
                                  enum: [SEV-1, SEV-2, SEV-3]
                                status:
                                  type: string
                                  enum: [investigating, identified, monitoring, resolved]
                                started_at: { type: string, format: date-time }
                                resolved_at:
                                  type: string
                                  format: date-time
                                  nullable: true
                                affected_components:
                                  type: array
                                  items: { type: string }
                                postmortem:
                                  type: string
                                  description: Optional reference to the internal post-mortem.
                                body_markdown:
                                  type: string
                                  description: Markdown body — render with the renderer of your choice.
                          count: { type: integer }
                    required: [data]
              example:
                data:
                  count: 1
                  incidents:
                    - slug: "2026-05-06-postgres-lock-table-full"
                      title: "[SEV-3] Indexer dropping ~1% of trades — Postgres lock-table-full"
                      severity: "SEV-3"
                      status: "resolved"
                      started_at: "2026-05-06T15:00:00Z"
                      resolved_at: "2026-05-06T22:39:00Z"
                      affected_components: ["indexer", "storage"]
                      body_markdown: "## Identification\n\nSome trades arriving on coinbase, binance…"
                as_of: "2026-05-06T22:40:00Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }
        "500": { $ref: "#/components/responses/InternalError" }

  /incidents.atom:
    get:
      tags: [meta]
      summary: Atom feed of customer-facing incidents.
      description: |
        RFC-4287 Atom 1.0 syndication of every customer-facing
        incident post (`internal/incidents/data/*.md`). Designed
        for Feedly, Slack's RSS bot, and similar feed consumers
        who want push-style notifications when a new incident
        ships without polling JSON.

        Same corpus as `/v1/incidents`; entry `<id>` is a stable
        URN so feed readers dedupe correctly across crawls.
        Cache-Control: public, max-age=300 (5 min) — the corpus
        only changes on redeploy so longer caching is fine.
      responses:
        "200":
          description: Atom 1.0 XML feed.
          content:
            application/atom+xml:
              schema:
                type: string
                description: |
                  RFC-4287 Atom feed — one `<entry>` per past
                  incident, sorted newest first, with `<title>`,
                  `<link>` to the status page anchor, `<summary>`
                  (first paragraph of the markdown body), and
                  full `<content>` (the markdown body).
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /coverage:
    get:
      tags: [meta]
      summary: Per-source completeness verdicts (ADR-0033) — the public trust surface.
      description: |
        Every indexed source's latest completeness verdict: the three
        provable claims (substrate continuity, recognition, projection
        reconciliation) plus the headline `complete` boolean, the
        verified-to watermark, and the first problem ledger when one
        exists. This is the same audit the operators run — published, so
        consumers can verify the "every protocol, verified complete"
        claim instead of trusting a badge.

        Verdicts change only when the completeness audit runs, so the
        endpoint is cheap and served with `public, max-age=60`.
      responses:
        "200":
          description: Latest verdict per source, source-sorted.
          content:
            application/json:
              example:
                data:
                  sources:
                  - source: aquarius
                    complete: true
                    substrate_ok: true
                    recognition_ok: true
                    projection_ok: true
                    genesis_ledger: 52728375
                    watermark_ledger: 63305532
                    tip_ledger: 63305532
                    coverage_pct: 1
                    detail: 'complete: substrate + recognition + projection verified to tip'
                    computed_at: '2026-07-03T05:30:21.937134Z'
                  complete_sources: 14
                  total_sources: 15
                as_of: '2026-07-03T22:38:20.564931481Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [sources, complete_sources, total_sources]
                        properties:
                          sources:
                            type: array
                            items:
                              type: object
                              required:
                                [source, complete, substrate_ok, recognition_ok,
                                 projection_ok, genesis_ledger, watermark_ledger,
                                 tip_ledger, coverage_pct, computed_at]
                              properties:
                                source: { type: string, example: soroswap }
                                complete: { type: boolean }
                                substrate_ok: { type: boolean }
                                recognition_ok: { type: boolean }
                                projection_ok: { type: boolean }
                                genesis_ledger: { type: integer, format: int64 }
                                watermark_ledger: { type: integer, format: int64 }
                                tip_ledger: { type: integer, format: int64 }
                                coverage_pct: { type: number }
                                first_problem_ledger: { type: integer, format: int64 }
                                detail: { type: string }
                                computed_at: { type: string, format: date-time }
                          complete_sources: { type: integer }
                          total_sources: { type: integer }
        "503":
          description: No completeness reader wired on this deployment.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
  /protocols:
    get:
      tags: [protocols]
      summary: Protocol directory — one row per indexed protocol.
      description: |
        The directory backing the explorer's Protocols pillar: every
        indexed protocol's hand-curated identity (category, one-line
        description, genesis ledger, verified factory / trust-root
        contracts) joined with three dynamic columns — registered
        contract-instance count (`protocol_contracts`; the
        `soroswap_pairs` registry for soroswap), trailing-24h decoded
        event count across the protocol's served tables, and the
        latest ADR-0033 completeness verdict summary (the same
        `completeness_snapshots` row `/coverage` serves in full).

        The static registry always serves; each dynamic join degrades
        independently to zero / absent when its reader isn't wired,
        so the endpoint never 5xxes on a partial deployment. Served
        with `public, max-age=60`.
      responses:
        "200":
          description: Every indexed protocol, registry-ordered.
          content:
            application/json:
              example:
                data:
                  protocols:
                  - name: sdex
                    category: dex
                    description: Stellar's protocol-native central-limit order book, traded via classic manage-offer and pa…
                    genesis_ledger: 2
                    factories: []
                    contract_count: 0
                    events_24h: 1692662
                    completeness:
                      complete: false
                      watermark_ledger: 63305532
                  total_protocols: 15
                as_of: '2026-07-03T22:38:22.870546219Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [protocols, total_protocols]
                        properties:
                          protocols:
                            type: array
                            items:
                              { $ref: "#/components/schemas/ProtocolRow" }
                          total_protocols: { type: integer }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /protocols/{name}:
    get:
      tags: [protocols]
      summary: Protocol deep-dive — directory row + contract registry + event vocabulary.
      description: |
        Everything the directory row carries plus the registered
        contract instances (`protocol_contracts` rows with deploying
        factory + first-observed ledger for ADR-0035-gated sources;
        the pair registry with token identities for soroswap; empty
        for sources without a registry), the `event_kinds` vocabulary
        the decoder emits, and the repo-relative `verification_page`
        when a public write-up exists. Served with
        `public, max-age=60`.
      parameters:
        - name: name
          in: path
          required: true
          description: Canonical protocol name from the directory (`blend`, `soroswap`, …).
          schema: { type: string, example: blend }
      responses:
        "200":
          description: The protocol's full detail view.
          content:
            application/json:
              example:
                data:
                  name: blend
                  category: lending
                  description: Blend — isolated lending pools on Soroban, deployed from the Blend pool factories.
                  genesis_ledger: 51499546
                  factories:
                  - CCZD6ESMOGMPWH2KRO4O7RGTAPGTUPFWFQBELQSS7ZUK63V3TZWETGAG
                  contract_count: 27
                  events_24h: 3211
                  completeness:
                    complete: true
                    watermark_ledger: 63305532
                  contracts:
                  - contract_id: CDVQVKOY2YSXS2IC7KN6MNASSHPAO7UN2UR2ON4OI2SKMFJNVAMDX6DP
                    factory_id: CCZD6ESMOGMPWH2KRO4O7RGTAPGTUPFWFQBELQSS7ZUK63V3TZWETGAG
                    first_ledger: 51499915
                    kind: instance
                    events: 569
                    last_seen: '2026-07-03T21:40:33Z'
                  event_kinds:
                  - blend.position
                  verification_page: docs/protocols/blend.md
                  event_breakdown:
                  - event_type: untyped
                    count: 332561
                  activity_series:
                  - date: '2026-03-22'
                    events: 2449
                  activity_window_days: 90
                  events_total: 332561
                as_of: '2026-07-03T22:38:49.052946129Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        allOf:
                          - { $ref: "#/components/schemas/ProtocolRow" }
                          - type: object
                            required: [contracts, event_kinds]
                            properties:
                              bespoke:
                                type: object
                                additionalProperties: true
                                description: |
                                  Per-category analytics block (dex / amm /
                                  lending / yield / oracle / bridge — see
                                  internal/api/v1/protocols.go ProtocolBespoke).
                                  Top-level keys vary by protocol category;
                                  documented as a free-form object because the
                                  per-category sub-shapes evolve with each
                                  protocol integration (spec'd loosely on
                                  purpose, board #33 — x-stability:
                                  experimental per ADR-0042 applies).
                              contracts:
                                type: array
                                items:
                                  type: object
                                  required: [contract_id]
                                  properties:
                                    contract_id: { type: string, description: Instance C-strkey. }
                                    factory_id: { type: string, description: Deploying factory C-strkey (gated sources only). }
                                    first_ledger: { type: integer, format: int64, description: First-observed ledger (absent when unknown). }
                                    token0: { type: string, description: Pair token0 C-strkey (soroswap only). }
                                    token1: { type: string, description: Pair token1 C-strkey (soroswap only). }
                                    kind: { type: string, enum: [factory, instance], description: Role within the protocol — a verified trust-root or a factory-deployed instance. }
                                    events: { type: integer, format: int64, description: "Decoded contract-event count for this instance over activity_window_days (from the lake)." }
                                    last_seen: { type: string, format: date-time, description: Close time of this instance's most recent event in the window. }
                              event_kinds:
                                type: array
                                items: { type: string, example: blend.position }
                              verification_page:
                                type: string
                                example: docs/protocols/blend.md
                              activity_window_days:
                                type: integer
                                description: Lookback (days) the lake-analytics fields below cover.
                                example: 90
                              events_total:
                                type: integer
                                format: int64
                                description: Total decoded contract events across the protocol over activity_window_days (sum of event_breakdown counts).
                              event_breakdown:
                                type: array
                                description: Event-type distribution (topic[0] symbol → count) over the window, descending — "which event types fired, and how often."
                                items:
                                  type: object
                                  properties:
                                    event_type: { type: string, example: supply_collateral }
                                    count: { type: integer, format: int64 }
                              activity_series:
                                type: array
                                description: Daily decoded-event count over the window (the activity chart).
                                items:
                                  type: object
                                  properties:
                                    date: { type: string, example: "2026-06-14" }
                                    events: { type: integer, format: int64 }
        "404":
          description: Unknown protocol name.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
  /ledger/tip:
    get:
      tags: [meta]
      summary: Live-ingest frontier — latest ingested ledger + lag.
      description: |
        A deliberately lightweight slice of `/v1/diagnostics/ingestion`:
        only the live-ingest frontier (highest ledger the indexer has
        committed) and its wall-clock age. Lets a status page or
        monitor poll "what ledger are we on" without pulling the full
        ingestion snapshot (24h volume, backfill state, supply, the
        source registry, …).

        `latest_ledger` is read from the `ledgerstream` row of the
        ingestion-cursors table — upserted once per ledger, so it is
        the freshest tip signal available. It is NOT identical to
        `/v1/diagnostics/ingestion`'s `ledger.latest_ledger`, which is
        derived from prices_1m's `MAX(ledger_sequence)` and only
        advances on ledgers that produced a trade row; the two agree
        within a few ledgers in steady state.

        Cache: `public, max-age=2` — the cursor advances every ~5s,
        so a 2s edge cache smooths a refreshing status page without
        hiding a stall. For push semantics use `/v1/ledger/stream`.
      responses:
        "200":
          description: Current live-ingest frontier.
          content:
            application/json:
              example:
                data:
                  latest_ledger: 63316183
                  ingested_at: '2026-07-03T22:38:47.833071Z'
                  lag_seconds: 2
                as_of: '2026-07-03T22:38:50.194761237Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [latest_ledger, ingested_at, lag_seconds]
                        properties:
                          latest_ledger:
                            type: integer
                            format: int64
                            description: Highest ledger the indexer has committed.
                          ingested_at:
                            type: string
                            format: date-time
                            description: When that ledger's cursor was committed (RFC 3339).
                          lag_seconds:
                            type: integer
                            format: int64
                            description: Wall-clock age of the cursor commit.
                    required: [data]
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /ledger/stream:
    get:
      tags: [meta]
      summary: SSE stream of the live-ingest frontier.
      description: |
        Streaming counterpart of `/v1/ledger/tip`. Pushes a
        `ledger_update` event each time the indexer commits a new
        ledger, so a status page renders blocks arriving in real time
        instead of polling.

        - First event fires synchronously on connect with the current
          tip.
        - Recurring events fire once per new ledger (poll cadence
          ~2s), plus a keepalive refresh every ~10s if the ledger has
          not advanced — so `lag_seconds` stays current even during an
          ingest stall.
        - Heartbeats every 15 s as comment lines.
        - Each event's `data` payload mirrors the `/v1/ledger/tip`
          envelope (`data` + `as_of`) so one type decodes both the
          polled and the streamed response.
        - 503 pre-flight when no cursors reader is wired or the live
          cursor has not been established yet — once the SSE body
          starts there is no way to signal a non-200 status.
      parameters:
        - name: Last-Event-ID
          in: header
          required: false
          schema: { type: string }
          description: Opaque ID for resuming a previously-broken stream.
      responses:
        "200":
          description: SSE stream of ledger_update events.
          content:
            text/event-stream:
              schema: { type: string }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /network/stats:
    get:
      tags: [meta]
      summary: Consolidated home-page aggregate stats.
      description: |
        Single-call replacement for the home network-strip's
        previous fan-out across `/v1/markets`, `/v1/assets`,
        `/v1/sources`, and `/v1/diagnostics/cursors`. Returns
        total trailing-24h USD volume, count of pairs that
        recorded volume in 24h, total `classic_assets` row count,
        latest live ledger across all non-backfill sources, and
        the count of registered exchange-class sources.

        Computed from a single SQL query over `prices_1m`,
        `classic_assets`, and `ingestion_cursors`. The source
        counts are derived from the in-memory `external.Registry`,
        not the DB.

        Useful for dashboards / status displays / embed widgets
        that just want a quick health-of-the-network number.
      responses:
        "200":
          description: Aggregate stats (standard envelope).
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [markets_count_24h, assets_indexed, latest_ledger, exchange_sources, total_sources]
                        properties:
                          volume_24h_usd:
                            type: string
                            nullable: true
                            description: |
                              SUM(prices_1m.volume_usd) over the trailing
                              24h. Decimal string per ADR-0003.
                          markets_count_24h:
                            type: integer
                            description: Distinct (base, quote) pairs with non-null volume in 24h.
                          assets_indexed:
                            type: integer
                            description: Total rows in classic_assets.
                          latest_ledger:
                            type: integer
                            description: Max last_ledger across non-backfill sources.
                          exchange_sources:
                            type: integer
                            description: |
                              Count of `class=exchange` sources REGISTERED in
                              the binary's `internal/sources/external.Registry`
                              map. Constant across regions running the same
                              build; independent of operator config.
                          total_sources:
                            type: integer
                            description: |
                              Count of ALL registered sources (every entry in
                              `internal/sources/external.Registry`). Different
                              from `/v1/status`'s `freshness.total_sources`,
                              which counts only sources the operator has
                              ENABLED at runtime — typically a strict subset.
                              Today on r1: registry=21, enabled=17, active=15.
                              The two `total_sources` measure different things
                              by design; see the field doc on
                              `internal/api/v1.NetworkStats` for the full
                              semantic table.
                    required: [data]
              example:
                data:
                  volume_24h_usd: "5941104763.13358600"
                  markets_count_24h: 4934
                  assets_indexed: 442190
                  latest_ledger: 62450017
                  exchange_sources: 11
                  total_sources: 21
                as_of: "2026-05-05T15:09:00.119Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /network/throughput:
    get:
      tags: [explorer]
      summary: Daily network throughput time-series.
      description: |
        Per-day network counts over the trailing `?window_days=`
        (default 30, max 365), ascending by day: ledgers closed,
        transactions, operations, and Soroban contract-events.
        Aggregated from the certified `stellar.ledgers` lake (which
        carries the per-ledger counts), bounded to the tip so it stays
        partition-pruned. The time-series companion to the snapshot at
        `/v1/network/stats`; backs the explorer `/network` charts.
      parameters:
        - { name: window_days, in: query, description: "Number of trailing days in the per-day series (1-365, default 30).", schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
      responses:
        "200":
          description: Daily throughput buckets.
          content:
            application/json:
              example:
                data:
                  window_days: 7
                  buckets:
                  - day: '2026-06-25'
                    ledgers: 3331
                    txs: 1068712
                    ops: 2398506
                    events: 1713900
                  - day: '2026-06-26'
                    ledgers: 14791
                    txs: 5232968
                    ops: 11025463
                    events: 7512422
                as_of: '2026-07-03T22:38:51.364370254Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      window_days: { type: integer }
                      buckets:
                        type: array
                        items:
                          type: object
                          properties:
                            day:     { type: string, format: date }
                            ledgers: { type: integer, format: int64 }
                            txs:     { type: integer, format: int64 }
                            ops:     { type: integer, format: int64 }
                            events:  { type: integer, format: int64 }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /methodology:
    get:
      tags: [meta]
      summary: Machine-readable summary of the active aggregation policy.
      description: |
        Returns a static projection of the aggregator's policy: the
        VWAP method, per-endpoint outlier filters, the operator's
        stablecoin → fiat-USD proxy allow-list, the four source
        classes (exchange / aggregator / oracle / authority_sanity)
        and which contributes to the served price, the flat list of
        registered venues with class / weight / VWAP-inclusion flags,
        and pointers to the long-form ADRs that govern each section.

        Designed for transparency consumers (compliance, auditors,
        AI agents, integrators verifying our open-methodology
        claims) who want to verify what the deployment is doing
        without parsing the explorer's HTML at /methodology or
        chasing ADR cross-refs. Sub-millisecond — no DB call;
        derived from compile-time constants + the in-memory source
        registry + operator config.
      responses:
        "200":
          description: Methodology snapshot.
          content:
            application/json:
              example:
                data:
                  version: '1.0'
                  aggregation:
                    price_method: vwap
                    outlier_filter:
                      endpoint: /v1/ohlc
                      default_sigma: 4
                      note: OHLC's High/Low have no statistical robustness; a single dust trade can pin them. The defa…
                    stablecoin_fiat_proxy:
                    - asset_id: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                      pegs_to: fiat:USD
                    closed_bucket_window_seconds: 30
                  source_classes:
                  - name: exchange
                    contributes_to_vwap: true
                    description: Real trading venues — DEXes (Soroswap, Phoenix, Aquarius, Comet, sdex), CEXes (Coinbase, B…
                  sources:
                  - name: aquarius
                    class: exchange
                    subclass: dex
                    default_weight: 100
                    include_in_vwap: true
                    paid: false
                    backfill_available: true
                    backfill_safe: true
                  references:
                  - id: ADR-0007
                    title: Aggregation policy + cache-key contract
                    url: /research/adr/0007
                as_of: '2026-07-03T22:38:52.512609229Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/MethodologyEnvelope" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sources:
    get:
      tags: [meta]
      summary: Source catalogue with class + IncludeInVWAP metadata.
      description: |
        Static projection of the aggregator's source registry — every
        venue we know about, labelled with the class semantic that
        decides whether it contributes to VWAP. Sources with
        `include_in_vwap=false` are intentional policy
        (aggregator/oracle/authority_sanity classes), not missing
        connectors. Operators consult this endpoint to confirm a
        venue is recognised before debugging an absence in /v1/markets
        or /v1/vwap.
      parameters:
        - name: class
          in: query
          required: false
          description: |
            Optional class filter. When set, only sources of the
            given class are returned. Useful for dashboards that
            split the catalogue by role.
          schema:
            type: string
            enum: [exchange, aggregator, oracle, authority_sanity, lending, router]
        - name: include
          in: query
          required: false
          description: |
            Opt-in extras. `stats` populates each row's
            `trade_count_24h` from a single GROUP BY on the trades
            hypertable — cheap, but a DB hit so opt-in. Absent the
            param the response stays the all-static-registry
            projection.
          schema:
            type: string
            enum: [stats]
      responses:
        "200":
          description: Array of sources, sorted by name.
          content:
            application/json:
              example:
                data:
                - name: aquarius
                  class: exchange
                  subclass: dex
                  include_in_vwap: true
                  paid: false
                  backfill_available: true
                  backfill_safe: true
                  default_weight: 100
                  on_chain: true
                as_of: '2026-07-03T22:38:53.6445723Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/SourcesEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sac-wrappers:
    get:
      tags: [meta]
      summary: Stellar-Asset-Contract wrapper resolution map.
      description: |
        Returns the operator-configured map of Stellar-Asset-Contract
        (SAC) C-strkey contract IDs to their underlying classic asset
        keys ("CODE-ISSUER", or `"native"` for XLM). Soroban DEX
        sources (Soroswap, Phoenix, Aquarius, Comet) emit base/quote
        as the SAC contract address in their swap events, not the
        underlying classic asset key — clients use this map to
        resolve raw `C…` contract addresses to readable asset
        symbols. The map only changes on API restart with new
        config; cache aggressively client-side.

        Empty object when the operator hasn't configured any
        wrappers (deployment without the `[supply.sac_wrappers]`
        block) — clients then degrade to showing the raw C-strkey.
      responses:
        "200":
          description: Map of SAC C-strkey to canonical asset key.
          content:
            application/json:
              example:
                data:
                  CA4L5XQ7FY7BTJAAD6VPW6JPSJ3M2A62BBULXH7XYHLHAOFFY6SBT2Z4: PSY:GCH3HFAY25TU2CPUEMF7OT7PGHUMXQITQQOOKZV6VRETY7SCEPARAEGO
                  CA57LR6W4XP7HTGJ3HZEXH7SVTMBPUBEUN5UI3WGB5HFKDMEECRJMXBZ: SILICA:GBDJWO2QRXHSOBQOPSZAK6B4COVPK5NMQI62XHC4L7YNYOSYCDLCVENZ
                  CA6FH7RO5YF7VZ3XDX4736S2YHIGCQBYGNWB7P23AM5OCQTBPDPIKEIP: FADA:GCX3Y4MNI7ZQBQEZQMAXRFVODVFB2PRQS4LTUHP5B34MEYQQTW5LQFLR
                as_of: '2026-07-03T22:38:54.77946276Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: { type: string }
                    description: SAC C-strkey → "CODE-ISSUER" or "native".
        "500": { $ref: "#/components/responses/InternalError" }

  /pairs:
    get:
      tags: [markets]
      summary: Direct markets for a pair.
      description: |
        Single-pair activity lookup — the point-query sibling of
        the pageable `/v1/markets` scan (same storage, same row
        shape). Returns a ZERO-OR-ONE-element array: the pair's
        activity summary when any trade has been observed,
        otherwise an empty array. A missing pair is deliberately
        NOT a 404, so clients can distinguish "no such pair" from
        a malformed request without branching on status code.
      parameters:
        - name: base
          in: query
          required: true
          schema:
            type: string
            example: native
          example: native
          description: Canonical asset id (e.g. `native`, `USDC-G…`).
        - name: quote
          in: query
          required: true
          schema:
            type: string
            example: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
          example: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
          description: Canonical asset id of the quote side.
      responses:
        "200":
          description: Pairs.
          content:
            application/json:
              example:
                data:
                - base: native
                  quote: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  last_trade_at: '2026-07-03T22:41:49Z'
                  bucket_close_at: '2026-07-03T00:00:00Z'
                  trade_count_24h: 44295
                  volume_24h_usd: '1184171.27591630'
                  last_price: '0.20425991362387122104'
                as_of: '2026-07-03T22:41:58.295925025Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/PairsEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /oracle/lastprice:
    get:
      tags: [oracle]
      summary: SEP-40 lastprice-equivalent passthrough.
      description: |
        HTTP mirror of the SEP-40 oracle contract call
        `lastprice(asset) -> Option<PriceData>` — for integrators
        that already speak Reflector's on-chain interface and want
        the identical shape over REST. The response is
        deliberately minimal (`price`, `timestamp`); the richer
        source/confidence view lives on `/v1/oracle/latest` and
        `/v1/price`. Quote is fixed at USD, matching the on-chain
        contract's fixed-quote semantic — for other quotes use
        `/v1/price?asset=&quote=` or `/v1/oracle/x_last_price`.
        404 when no observation exists for the asset.
      parameters:
        - name: asset
          in: query
          required: true
          schema:
            type: string
            example: "crypto:XLM"
          example: "crypto:XLM"
          description: |
            SEP-40 oracle key. Reflector contracts publish under
            `crypto:<symbol>` (`crypto:XLM`, `crypto:BTC`,
            `crypto:USDC`, `crypto:ETH`, `crypto:EUROB`); the bare
            `native` / `<code>-<G…>` forms are NOT keys in the
            oracle namespace and return 404 here. Use
            `/v1/price?asset=…` for canonical-asset prices.
      responses:
        "200":
          description: Price record.
          content:
            application/json:
              example:
                data:
                  asset: crypto:XLM
                  price: '0.20425919235975054096'
                  timestamp: '2026-07-03T22:38:00Z'
                as_of: '2026-07-03T22:38:57.085626393Z'
                sources:
                - bitstamp
                - coinbase
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/OraclePriceEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /oracle/prices:
    get:
      tags: [oracle]
      summary: SEP-40 prices-equivalent passthrough (N historical).
      description: |
        HTTP mirror of the SEP-40 oracle contract call
        `prices(asset, records) -> Option<Vec<PriceData>>` — "the
        last N price records". Returns up to `records` most-recent
        CLOSED 1-minute VWAP snapshots for the asset/USD pair,
        newest first; the in-progress bucket is excluded
        (ADR-0015). 200 with an empty array when the asset has no
        closed buckets yet.
      parameters:
        - name: asset
          in: query
          required: true
          schema:
            type: string
            example: "crypto:XLM"
          example: "crypto:XLM"
        - name: records
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 60 }
      responses:
        "200":
          description: Historical records.
          content:
            application/json:
              example:
                data:
                - asset: crypto:XLM
                  price: '0.20425919235975054096'
                  timestamp: '2026-07-03T22:38:00Z'
                - asset: crypto:XLM
                  price: '0.20417645386978830788'
                  timestamp: '2026-07-03T22:37:00Z'
                as_of: '2026-07-03T22:38:58.304524994Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/OraclePricesEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /oracle/x_last_price:
    get:
      tags: [oracle]
      summary: SEP-40 x_last_price-equivalent (cross-pair).
      description: |
        HTTP mirror of the SEP-40 oracle contract call
        `x_last_price(base, quote)` — last observed price of
        `base` denominated in `quote`. The response's `asset`
        field carries the canonical base identifier so existing
        SEP-40 `lastprice` parsing paths can be reused; the quote
        is implicit from the request. 404 when no observation
        exists for the pair.
      parameters:
        - name: base
          in: query
          required: true
          schema:
            type: string
            example: native
          example: native
        - name: quote
          in: query
          required: true
          schema:
            type: string
            example: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
          example: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
      responses:
        "200":
          description: Cross-pair last price.
          content:
            application/json:
              example:
                data:
                  asset: native
                  price: '0.20416863139584866478'
                  timestamp: '2026-07-03T22:38:00Z'
                as_of: '2026-07-03T22:38:59.458170934Z'
                sources:
                - sdex
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/OraclePriceEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /account/me:
    get:
      tags: [account]
      summary: API-key holder info + quota status.
      description: |
        Who am I? Returns the authenticated caller's identity.
        API-key / SEP-10 callers get the top-level `key_*` fields
        (key id, label, tier, per-minute rate limit); dashboard
        session callers get the nested `user` / `account` objects
        instead. When both credentials ride one request the
        session wins — it identifies a person, a key only
        identifies a credential. Anonymous callers get 401: /me
        is meaningless without a credential.
      security:
        - APIKeyAuth: []
        - SessionCookie: []
      responses:
        "200":
          description: Account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountEnvelope" }
              example:
                data:
                  key_id: kid_8f3a2c1b9e7d4f6a
                  label: production-api-1
                  tier: apikey
                  rate_limit_per_min: 1000
                  created_at: "2026-04-12T09:32:18Z"
                as_of: "2026-05-05T16:25:42.881Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/usage:
    get:
      tags: [account]
      summary: Daily request counters for the authenticated key.
      description: |
        Returns per-day request counts for the authenticated caller
        over the last 31 days (or the `from`/`to` window when
        supplied). Counts are incremented by the usage middleware on
        every successful (non-rate-limited, non-policy-denied)
        request and persisted in Redis under the `usage:<subject>:
        <YYYY-MM-DD>` key family.

        F-1259 (codex audit-2026-05-12) — the previous "placeholder,
        returns empty list" wording reflected the pre-launch state;
        the counter is now wired in `cmd/stellarindex-api/main.go`
        whenever Redis is reachable. Deployments without Redis
        (e.g. local dev with `-no-redis`) still return an empty
        envelope — the absence of the counter is reflected on
        /v1/readyz under `checks` (NOT `/v1/healthz` — the
        per-dependency `checks` field is `/readyz`-only; the
        `/healthz` liveness probe stays minimal by design).
      security:
        - APIKeyAuth: []
      parameters:
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
      responses:
        "200":
          description: Usage records.
          content:
            application/json:
              example:
                data:
                - date: '2026-07-01'
                  requests: 18234
                  errors: 12
                  throttled: 0
                - date: '2026-07-02'
                  requests: 20117
                  errors: 3
                  throttled: 41
                as_of: '2026-07-03T09:00:00Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/UsageEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/keys:
    get:
      tags: [account]
      summary: List all API keys for the authenticated caller.
      description: |
        Returns every key whose Identifier matches the caller's
        authenticated Subject, sorted oldest-first. Each entry is
        the public-safe projection — `key_id`, `label`, `tier`,
        `rate_limit_per_min`, `created_at`. **Plaintext is never
        returned**; it's only retrievable at POST-time.

        Use this to render an account dashboard ("here are your
        keys"), verify rotation worked, or confirm a Stripe-paid
        upgrade lifted the right keys.
      security:
        - APIKeyAuth: []
      responses:
        "200":
          description: Account keys (possibly empty list).
          content:
            application/json:
              example:
                data:
                - key_id: 7d9f2a54-4f0e-4c1a-9b3d-2f6c8e1a0b5c
                  label: production
                  key_prefix: sip_1a2b3c4d
                  tier: apikey
                  rate_limit_per_min: 300
                  created_at: '2026-06-12T08:30:00Z'
                as_of: '2026-07-03T09:00:00Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Account" }
        "401":
          description: Unauthenticated.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          description: AccountStore not configured.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    post:
      tags: [account]
      summary: Create a new API key.
      description: |
        Issues a new API key for the authenticated caller and returns
        the plaintext exactly once.
      security:
        - APIKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 128 }
              required: [label]
      responses:
        "201":
          description: New key — plaintext shown **once**.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KeyCreatedEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /account/keys/{keyID}:
    delete:
      tags: [account]
      summary: Revoke an API key by KeyID.
      description: |
        Revokes the API key whose KeyID matches the path parameter,
        scoped to the authenticated caller's Identifier so accounts
        can only revoke their own keys. "Not found" and "not yours"
        collapse to 204 to prevent cross-account enumeration probes.

        The caller cannot revoke the key they're authenticated with —
        that would orphan the connection mid-request. 409 in that
        case so the UI can prompt for an alternate credential.
      security:
        - APIKeyAuth: []
      parameters:
        - name: keyID
          in: path
          required: true
          schema: { type: string }
          description: "Public-safe `kid_<hex>` identifier from /v1/account/keys."
      responses:
        "204":
          description: Revoked (or no-op when the key didn't belong to the caller).
        "400":
          description: Missing keyID.
        "401":
          description: Unauthenticated.
        "409":
          description: Caller tried to revoke the key they're using.
        "503":
          description: Account store not configured.

  /webhooks/stripe:
    post:
      tags: [account]
      summary: Stripe webhook — paid-tier upgrade.
      description: |
        Receives Stripe webhook events. On `checkout.session.completed`
        with `payment_status=paid`, looks up every API key whose
        identifier matches `client_reference_id` and lifts their
        `rate_limit_per_min` to the value implied by `metadata.tier`
        (pro / business) or the explicit `metadata.rate_limit_per_min`
        override.

        Idempotent — Stripe at-least-once delivery means the same
        event may arrive multiple times; the handler always sets the
        same target rate-limit. Customer keeps their existing
        plaintext key; effective on the next request (validator reads
        the per-key budget on every Lookup).

        Stripe-Signature header verified via HMAC-SHA256 with
        timestamp drift bounded to 5 minutes. Endpoint returns 503
        when the signing secret is unset (deployments without
        Stripe).

        **Operator observability.** Beyond the Redis-side
        rate-limit lift (which 5xx-bubbles back to Stripe so the
        platform retries), the handler also fans out platform-store
        side effects: account tier update, subscription upsert, and
        per-key rate-limit lift on dashboard-created Postgres keys
        (F-1219). Those side effects are best-effort — they do NOT
        5xx — because Stripe retries against an unhealthy Postgres
        would just retry-storm. Failures are surfaced via the
        `stellarindex_stripe_platform_sync_errors_total{operation}`
        counter; the alert
        `stellarindex_stripe_platform_sync_errors` (P3 / ticket)
        fires on any non-zero rate over 15 min. See the runbook
        at `docs/operations/runbooks/stripe-platform-sync-errors.md`
        for per-`operation` triage.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Event acknowledged (regardless of whether keys were upgraded — non-2xx triggers Stripe retries).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:                { type: boolean }
                  upgraded:          { type: integer, description: "Keys upgraded to the new rate limit." }
                  keys_total:        { type: integer, description: "Total keys belonging to the identifier." }
                  rate_limit_per_min: { type: integer }
                  ignored:           { type: string, description: "Set when an event was acknowledged but not acted on (wrong type, unpaid)." }
        "400":
          description: Missing signature header / bad body / bad metadata.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401":
          description: Stripe-Signature verification failed.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          description: Webhook signing secret not configured.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /signup:
    post:
      tags: [account]
      summary: Self-service signup — mint a first API key by email.
      description: |
        Public, anonymous-tier endpoint. Hit it once with an email
        + optional label and get back a freshly-minted API key for
        the Starter tier (1000 req/min). Idempotent on the email:
        a second call for the same email returns 409 with a pointer
        to the existing key (recover access via support; future
        Stripe-paid upgrade flow will support self-service rotation
        via /v1/account/keys).

        Already-authenticated callers receive 400 — they should
        rotate keys via POST /v1/account/keys instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                label: { type: string, maxLength: 128 }
              required: [email]
            example:
              email: "alice@example.com"
              label: "production-api-1"
      responses:
        "200":
          description: Account created — plaintext key shown **once**.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          plaintext:         { type: string, description: "Bearer token. Show ONCE; unrecoverable." }
                          key_id:            { type: string }
                          identifier:        { type: string }
                          label:             { type: string }
                          tier:              { type: string, enum: [apikey] }
                          rate_limit_per_min: { type: integer }
                        required: [plaintext, key_id, identifier, tier, rate_limit_per_min]
              example:
                data:
                  plaintext: "re_live_4f9c1d8b3a7e2f1c9d4b8a6e3f2c1d9b8a7e6f5d4c3b2a1f"
                  key_id: "k_8f3a2c1b9e7d4f6a"
                  identifier: "signup-3d4f9a2c1e8b7f6d"
                  label: "production-api-1"
                  tier: "apikey"
                  rate_limit_per_min: 1000
                as_of: "2026-05-05T14:35:42.881Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }
        "400":
          description: Missing or invalid email, body too large, or already authenticated.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: Email already has an account.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          description: AccountStore not configured (Redis unavailable).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /signup/verify:
    get:
      tags: [account]
      summary: Confirm email ownership for a signup-issued API key.
      description: |
        F-1218 (codex audit-2026-05-12): closes the email-
        ownership-proof loop on `POST /v1/signup`. The signup
        handler issues a single-use token and emails it to the
        submitted address; this endpoint consumes the token from
        the click-through link.

        Single-use semantics via Redis GETDEL — the second
        click on the link returns 404, the same shape as a
        forged or expired token. Token TTL defaults to 24h to
        match the dashboard magic-link convention.

        Subsequent waves layer:
          1. The email-send step on POST /v1/signup that
             populates the verifier with the issued token.
          2. An optional validator gate (operator opt-in via
             config) that rejects unverified keys with 403.

        Today this endpoint just consumes the token; the
        success path returns the key_id so the customer's
        dashboard / CLI can correlate the verified key.
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
          description: The plaintext token from the verification email.
      responses:
        "200":
          description: Token consumed; email ownership confirmed.
          content:
            application/json:
              example:
                data:
                  verified: true
                  key_id: 7d9f2a54-4f0e-4c1a-9b3d-2f6c8e1a0b5c
                  detail: email verified; API key activated
                as_of: '2026-07-03T09:00:00Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          verified: { type: boolean }
                          key_id:   { type: string }
                          detail:   { type: string }
                        required: [verified]
        "400":
          description: Missing `?token=` query parameter.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Unknown / consumed / expired token.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          description: SignupVerifier not configured (Redis unavailable).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/keys:
    get:
      tags: [dashboard]
      summary: Customer dashboard — list this account's API keys.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Returns every key (active + revoked) for
        the authenticated user's account, ordered oldest-first.
        Plaintext is never returned by this endpoint — only
        the prefix (`sip_4f9c1d8b…`).
      responses:
        "200":
          description: Key list.
          content:
            application/json:
              example:
                keys:
                - id: 7d9f2a54-4f0e-4c1a-9b3d-2f6c8e1a0b5c
                  name: production
                  description: main backend key
                  key_prefix: sip_1a2b3c4d
                  tier: apikey
                  rate_limit_per_min: 300
                  monthly_quota: 1000000
                  usage_alert_threshold_pct: 80
                  last_used_at: '2026-07-03T08:59:41Z'
                  created_at: '2026-06-12T08:30:00Z'
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/DashboardKey" }
                required: [keys]
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    post:
      tags: [dashboard]
      summary: Customer dashboard — mint a new API key.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Mints a new key, returns the plaintext
        ONCE. The dashboard surfaces it in a "save this now"
        banner; subsequent reads only see the prefix. Owner /
        admin / member roles can mint; viewer + billing 403.
        Rate-limited to MaxKeysPerAccount=25 active keys.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateKeyRequest" }
      responses:
        "201":
          description: Key minted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateKeyResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Role can't mint keys.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: Account already at the active-key quota.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/keys/{id}:
    delete:
      tags: [dashboard]
      summary: Customer dashboard — revoke a key.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Soft-delete by setting revoked_at;
        idempotent. 404 when the key doesn't exist OR belongs
        to a different account (same shape so attackers can't
        enumerate cross-account key_ids).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "204":
          description: Revoked.
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Role can't revoke keys.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Key not found.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/webhooks:
    get:
      tags: [dashboard]
      summary: Customer dashboard — list this account's webhooks.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Returns every webhook this account has
        registered to receive incident / anomaly / divergence
        callbacks, newest first. The signing secret is never
        returned — only the URL, events, and enabled flag. F-1270.
      responses:
        "200":
          description: Webhook list.
          content:
            application/json:
              example:
                webhooks:
                - id: 0b6a3f2e-9c1d-4e7a-8f5b-6d2c4a1e9b0f
                  name: ops-alerts
                  url: https://ops.example.com/hooks/stellarindex
                  events:
                  - incident.sev1
                  - incident.resolved
                  enabled: true
                  created_at: '2026-06-20T10:00:00Z'
                  updated_at: '2026-06-20T10:00:00Z'
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/DashboardWebhook" }
                required: [webhooks]
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    post:
      tags: [dashboard]
      summary: Customer dashboard — register a new webhook.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Returns the signing secret ONCE — store it
        server-side immediately and use it to HMAC-verify the
        X-StellarIndex-Signature header on inbound POSTs. URL must
        be https://. Owner / admin / member roles can register;
        viewer + billing 403. Capped at 10 webhooks per account.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookRequest" }
      responses:
        "201":
          description: Webhook registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateWebhookResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: Role can't manage webhooks.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: Account at the 10-webhook quota.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/webhooks/{id}:
    patch:
      tags: [dashboard]
      summary: Customer dashboard — update a webhook.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Patches name / url / events / enabled.
        SecretHash is immutable; rotation lives behind a separate
        endpoint when it ships.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateWebhookRequest" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardWebhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Webhook not found.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    delete:
      tags: [dashboard]
      summary: Customer dashboard — delete a webhook.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Hard-deletes the registry row and cascades
        to webhook_deliveries. Idempotent — 204 on absent.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: Deleted.
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/webhooks/{id}/deliveries:
    get:
      tags: [dashboard]
      summary: Customer dashboard — list recent delivery attempts.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Most-recent first; up to 100 attempts.
        Powers the dashboard's delivery-log panel.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Delivery log.
          content:
            application/json:
              example:
                deliveries:
                - id: 5c8e1f0a-2b4d-4a6c-9e3f-7d1b0c5a8e2f
                  event_type: incident.sev1
                  attempt_count: 1
                  next_attempt_at: null
                  delivered_at: '2026-07-01T14:03:22Z'
                  last_error: null
                  last_response_status: 200
                  created_at: '2026-07-01T14:03:21Z'
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDeliveryDTO" }
                required: [deliveries]
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Webhook not found.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /auth/login:
    post:
      tags: [auth]
      summary: Customer dashboard — request a magic-link sign-in email.
      description: |
        Mints a single-use magic-link token (valid 15 minutes) and
        emails it to the address. Always returns `{status: "sent"}`
        regardless of whether the email matches an existing account
        — leaking that information would let attackers enumerate
        valid users. First-time users have their account + owner-
        user provisioned on the callback (not here), so the same
        flow handles login + signup.

        The email carries both a one-click magic link (consumed by
        `/auth/callback`) and a 6-digit code (consumed by
        `/auth/verify-code`) — either signs the user in.

        Distinct from `/auth/sep10/*` (programmatic API auth via a
        signed Stellar challenge). This endpoint is the entry point
        for the cookie-based in-site dashboard at stellarindex.io/account.

        Returns 503 when the deployment hasn't configured the
        dashboard auth flow (api.dashboard.base_url empty).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  description: The address to email the magic link to.
              required: [email]
      responses:
        "200":
          description: Email accepted for delivery (or silently dropped if address doesn't match an account).
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [sent]
                required: [status]
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/callback:
    get:
      tags: [auth]
      summary: Customer dashboard — consume a magic-link token + mint a session cookie.
      description: |
        Reads the `token` query parameter, atomically marks it
        consumed in `magic_link_tokens`, and on success sets an
        HttpOnly + Secure session cookie (`stellarindex_session`)
        and redirects (303) into the dashboard.

        First-time emails get a free-tier account + owner-role
        user provisioned at this step. Returning users skip
        provisioning and just get a fresh session.

        Returns 410 on expired tokens so the dashboard can render
        a "request a fresh link" prompt distinct from the generic
        "invalid token" 400.
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
          description: Hex-encoded magic-link plaintext from the email.
        - name: next
          in: query
          required: false
          schema: { type: string }
          description: |
            Path-only redirect target after sign-in (e.g. `/keys`).
            Absolute URLs and protocol-relative paths (`//evil.com`)
            are rejected; the user lands at `/` instead.
      responses:
        "303":
          description: Authenticated; session cookie set; redirect to dashboard.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: HttpOnly + Secure session cookie.
        "400": { $ref: "#/components/responses/BadRequest" }
        "410":
          description: Magic-link token expired; request a fresh one.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/verify-code:
    post:
      tags: [auth]
      summary: Customer dashboard — exchange the 6-digit email code for a session cookie.
      description: |
        The paste-friendly alternative to clicking the magic link. The
        sign-in email carries both a clickable link (consumed by
        `/auth/callback`) and a 6-digit numeric code; this endpoint
        consumes the code. The SPA calls it via a credentialed `fetch`
        — on success it sets the same HttpOnly + Secure session cookie
        (`stellarindex_session`) the callback does and returns
        `{status:"ok"}` (no redirect; the SPA navigates itself).

        First-time emails get a free-tier account + owner-role user
        provisioned here, exactly as on the callback path.

        The code is matched only against the email's in-flight login
        tokens, and each wrong guess burns an attempt — after a small
        cap the token stops being a code candidate (the link still
        works). All failure modes — no outstanding token, wrong code,
        expired, too many attempts — return the same generic 400 so a
        caller can't probe which one occurred.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  description: The address the code was sent to.
                code:
                  type: string
                  pattern: '^[0-9]{6}$'
                  description: The 6-digit numeric code from the sign-in email.
              required: [email, code]
      responses:
        "200":
          description: Authenticated; session cookie set.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: HttpOnly + Secure session cookie.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                required: [status]
        "400":
          description: Malformed input, or invalid/expired/exhausted code (generic — modes are indistinguishable).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/logout:
    post:
      tags: [auth]
      summary: Customer dashboard — revoke the current session + clear the cookie.
      description: |
        Idempotent. Calling without a session cookie is still a 200
        — the goal is just to leave the browser in a known
        signed-out state. Sets a Max-Age=-1 cookie so the browser
        drops it on the next response.
      responses:
        "200":
          description: Session revoked (if any) and cookie cleared.
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/sep10/challenge:
    get:
      tags: [auth]
      summary: SEP-10 Web Auth — issue a challenge transaction.
      description: |
        Returns a Stellar challenge transaction the client must sign
        with its account secret key. Per SEP-10 §3.2 the response
        carries the unsigned XDR + the network passphrase the
        challenge was crafted for. Unauthenticated by design.
      parameters:
        - name: account
          in: query
          required: true
          schema: { type: string }
          description: Stellar G-strkey of the account being authenticated.
      responses:
        "200":
          description: Unsigned challenge transaction.
          content:
            application/json:
              example:
                data:
                  transaction: AAAAAgAAAADg3G3hclysZlFitS+s5zWyiiJD5B0STWy5LXCj6i5yxQAAAMgAAAAAAAAAAA…
                  network_passphrase: Public Global Stellar Network ; September 2015
                  issued_at: '2026-07-03T09:00:00Z'
                  valid_until: '2026-07-03T09:15:00Z'
                as_of: '2026-07-03T09:00:00Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/SEP10Challenge" }
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/sep10/token:
    post:
      tags: [auth]
      summary: SEP-10 Web Auth — exchange a signed challenge for a JWT.
      description: |
        Accepts a signed challenge transaction and returns a JWT
        bearing the authenticated G-strkey. Per SEP-10 §3.3 the body
        carries `{transaction: <base64-XDR>}`. Unauthenticated by
        design — the SEP-10 protocol IS the authentication.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                transaction:
                  type: string
                  description: Base64-encoded signed XDR of the challenge.
              required: [transaction]
      responses:
        "200":
          description: Authenticated; JWT issued.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/SEP10Token" }
                    required: [data]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Signature verification failed.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "410":
          description: Challenge time-bounds expired; request a fresh challenge.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  # ───────────────────────── Network explorer (ADR-0038) ─────────────────────
  # Read the certified ClickHouse Tier-1 lake (full chain to genesis). Results
  # are keyed on immutable identity (ledger_seq / tx_hash) so they're cacheable
  # indefinitely. 503 when the deployment has no ClickHouse explorer reader.
  /ledgers:
    get:
      tags: [explorer]
      summary: Recent ledgers (descending, keyset-paged).
      description: |
        Recent closed ledgers, newest first. Keyset-page older with
        `?before=<sequence>` (use the response's `next_before`).
      parameters:
        - { name: limit, in: query, description: "Maximum rows to return (1-200, default 50). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: before, in: query, description: Return ledgers with sequence < this., schema: { type: integer } }
      responses:
        "200":
          description: A page of ledger headers.
          content:
            application/json:
              example:
                data:
                  ledgers:
                  - sequence: 63316166
                    close_time: '2026-07-03T22:37:01Z'
                    hash: 734ef513eec0746fb133bc0a759adb98ffae7fa6e36e286954531a5416b437cb
                    prev_hash: 58aee267a054f990b426f932b5c518151f789da48e76153d9722769b4d7a8b08
                    protocol_version: 26
                    tx_count: 299
                    op_count: 1079
                    soroban_event_count: 1105
                    total_coins: '1054439020873472865'
                    fee_pool: '100768724524038'
                    base_fee: 100
                    base_reserve: 5000000
                  - sequence: 63316165
                    close_time: '2026-07-03T22:36:56Z'
                    hash: 58aee267a054f990b426f932b5c518151f789da48e76153d9722769b4d7a8b08
                    prev_hash: ada19f6cb122d0b74b98b7dc53ef7be4e5463c86a1af14185c0ae741704986cb
                    protocol_version: 26
                    tx_count: 277
                    op_count: 767
                    soroban_event_count: 708
                    total_coins: '1054439020873472865'
                    fee_pool: '100768720236504'
                    base_fee: 100
                    base_reserve: 5000000
                  next_before: 63316165
                as_of: '2026-07-03T22:37:10.384389772Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ledgers: { type: array, items: { $ref: "#/components/schemas/Ledger" } }
                      next_before: { type: integer }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /ledgers/{seq}:
    get:
      tags: [explorer]
      summary: A single ledger header.
      description: |
        One closed ledger's header from the certified lake —
        close time, transaction/operation counts, fee pool, and
        protocol version. The lake reaches back to genesis, so any
        historical sequence resolves; results are immutable and
        cacheable indefinitely. 404 when `seq` is beyond the
        ingest tip (or otherwise outside the indexed range).
      parameters:
        - { name: seq, in: path, required: true, schema: { type: integer }, example: 63000000 }
      responses:
        "200":
          description: Ledger header.
          content:
            application/json:
              example:
                data:
                  sequence: 63316166
                  close_time: '2026-07-03T22:37:01Z'
                  hash: 734ef513eec0746fb133bc0a759adb98ffae7fa6e36e286954531a5416b437cb
                  prev_hash: 58aee267a054f990b426f932b5c518151f789da48e76153d9722769b4d7a8b08
                  protocol_version: 26
                  tx_count: 299
                  op_count: 1079
                  soroban_event_count: 1105
                  total_coins: '1054439020873472865'
                  fee_pool: '100768724524038'
                  base_fee: 100
                  base_reserve: 5000000
                as_of: '2026-07-03T22:39:59.693177604Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties: { data: { $ref: "#/components/schemas/Ledger" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { description: Ledger not in the indexed range. }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /ledgers/{seq}/transactions:
    get:
      tags: [explorer]
      summary: Transactions in a ledger.
      description: |
        Every transaction applied in ledger `seq`, in apply order:
        hash, source account, operation count, fee charged, and
        success flag. Summary rows only — fetch `/v1/tx/{hash}`
        for the decoded operations + events of one transaction.
        A valid but empty ledger returns an empty array.
      parameters:
        - { name: seq, in: path, required: true, schema: { type: integer }, example: 63000000 }
        - { name: limit, in: query, description: "Maximum transactions to return (1-1000, default 200).", schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } }
      responses:
        "200":
          description: The ledger's transactions.
          content:
            application/json:
              example:
                data:
                  ledger: 63316166
                  transactions:
                  - hash: 5b0ae3dc05f628f53292ab19702a42f083193fd8059ed9dd093fd2796ac8745a
                    ledger: 63316166
                    close_time: '2026-07-03T22:37:01Z'
                    index: 0
                    source_account: GBFTDB5ZFZLXSQGDFA3LHAPDFFWENVWWKXYB3VHRF345WV3AD32ZEVHP
                    fee_charged: 600
                    max_fee: 120000
                    operation_count: 6
                    successful: false
                    result_code: -1
                    memo_type: none
                as_of: '2026-07-03T22:39:58.530334586Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ledger: { type: integer }
                      transactions: { type: array, items: { $ref: "#/components/schemas/TxSummary" } }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /tx/{hash}:
    get:
      tags: [explorer]
      summary: Transaction detail — summary + decoded operations + events.
      description: |
        Full transaction: the summary, every operation decoded from XDR into
        clean JSON (with its result code), and the contract events it emitted.
      parameters:
        - { name: hash, in: path, required: true, description: 64-char hex transaction hash., schema: { type: string } }
      responses:
        "200":
          description: Transaction detail.
          content:
            application/json:
              example:
                data:
                  hash: 5b0ae3dc05f628f53292ab19702a42f083193fd8059ed9dd093fd2796ac8745a
                  ledger: 63316166
                  close_time: '2026-07-03T22:37:01Z'
                  index: 0
                  source_account: GBFTDB5ZFZLXSQGDFA3LHAPDFFWENVWWKXYB3VHRF345WV3AD32ZEVHP
                  fee_charged: 600
                  max_fee: 120000
                  operation_count: 6
                  successful: false
                  result_code: -1
                  memo_type: none
                  operations:
                  - ledger: 63316166
                    close_time: '2026-07-03T22:37:01Z'
                    tx_hash: 5b0ae3dc05f628f53292ab19702a42f083193fd8059ed9dd093fd2796ac8745a
                    tx_index: 0
                    op_index: 0
                    type: payment
                    source_account: GBFTDB5ZFZLXSQGDFA3LHAPDFFWENVWWKXYB3VHRF345WV3AD32ZEVHP
                    fields:
                      amount: '26000000000000'
                      asset: XLM26-GD3CO7CGKHQKJ6LFGCXBOXHF5CJNVJ346AHQWA4RLVTVPCDYGCGWWCOL
                      destination: GDKRYQ4K45I6MYOQ3256TOAVCHD7AZIW4O2GEF6VACE6I2ZDX7XA6RJV
                    result_code: 0
                as_of: '2026-07-03T22:40:09.331893286Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties: { data: { $ref: "#/components/schemas/TxDetail" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { description: No transaction with that hash in the indexed range. }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /operations:
    get:
      tags: [explorer]
      summary: Operations — a ledger's ops, or the network-wide recent directory.
      description: |
        Two shapes on one route:

        - **`?ledger=<seq>`** — that ledger's operations, each decoded from XDR
          (partition-pruned; `?limit=` up to 2000, default 500). Includes the
          decoded body (`fields` / `raw_xdr`).
        - **no `?ledger`** — the network-wide recent-operations DIRECTORY:
          newest first, keyset-paged via `?cursor=<opaque>` (echo back
          `next_cursor`; composite `ledger.tx_index.op_index`), plus
          `op_type_stats` — the per-op-type counts over the trailing ~24h of
          ledgers (first page only). `?limit=` up to 200, default 50. This is a
          **summary** shape: each op carries its identity + `type` but NOT the
          decoded body (`fields` / `raw_xdr` are omitted) — decoding every op's
          body over the multi-billion-row lake made the directory ~10× slower.
          Fetch the full decoded op from the per-ledger form above or
          `/v1/tx/{hash}`.
      parameters:
        - { name: ledger, in: query, description: Ledger sequence. Omit for the network-wide recent directory., schema: { type: integer } }
        - { name: cursor, in: query, description: Opaque keyset cursor (directory mode only)., schema: { type: string } }
        - { name: limit, in: query, description: "Page size. Mode-dependent bounds — per-ledger mode: default 500, cap 2000; directory mode: default 50, cap 200.", schema: { type: integer, minimum: 1, maximum: 2000 } }
      responses:
        "200":
          description: Decoded operations (ledger-scoped or the recent directory).
          content:
            application/json:
              example:
                data:
                  ledger: 63316166
                  operations:
                  - ledger: 63316166
                    close_time: '2026-07-03T22:37:01Z'
                    tx_hash: 5b0ae3dc05f628f53292ab19702a42f083193fd8059ed9dd093fd2796ac8745a
                    tx_index: 0
                    op_index: 0
                    type: payment
                    source_account: GBFTDB5ZFZLXSQGDFA3LHAPDFFWENVWWKXYB3VHRF345WV3AD32ZEVHP
                    fields:
                      amount: '26000000000000'
                      asset: XLM26-GD3CO7CGKHQKJ6LFGCXBOXHF5CJNVJ346AHQWA4RLVTVPCDYGCGWWCOL
                      destination: GDKRYQ4K45I6MYOQ3256TOAVCHD7AZIW4O2GEF6VACE6I2ZDX7XA6RJV
                  - ledger: 63316166
                    close_time: '2026-07-03T22:37:01Z'
                    tx_hash: 5b0ae3dc05f628f53292ab19702a42f083193fd8059ed9dd093fd2796ac8745a
                    tx_index: 0
                    op_index: 1
                    type: payment
                    source_account: GBFTDB5ZFZLXSQGDFA3LHAPDFFWENVWWKXYB3VHRF345WV3AD32ZEVHP
                    fields:
                      amount: '2600000000000'
                      asset: XRP26-GD3CO7CGKHQKJ6LFGCXBOXHF5CJNVJ346AHQWA4RLVTVPCDYGCGWWCOL
                      destination: GDKRYQ4K45I6MYOQ3256TOAVCHD7AZIW4O2GEF6VACE6I2ZDX7XA6RJV
                as_of: '2026-07-03T22:40:10.560512468Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ledger: { type: integer, description: "The ledger (ledger-scoped mode); 0 in directory mode." }
                      operations: { type: array, items: { $ref: "#/components/schemas/Operation" } }
                      next_cursor: { type: string, description: "Directory mode: opaque cursor for the next older page; absent on the last page." }
                      op_type_stats:
                        type: array
                        description: "Directory mode, first page only: per-op-type counts over the trailing ~24h."
                        items:
                          type: object
                          properties:
                            type:  { type: string }
                            count: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts:
    get:
      tags: [explorer]
      summary: Contracts directory — most active contracts over a recent window.
      description: |
        The most active Soroban contracts ranked by emitted-event count over a
        recent window (`?days=`, default 30, max 365). Each entry is tagged with
        its owning `protocol` when the contract is in the factory-anchored
        registry (ADR-0035) — the attribution hinge that lets the explorer say
        "this contract IS a Blend pool". `since_ledger` echoes the window floor.
      parameters:
        - { name: days, in: query, description: Window size in days., schema: { type: integer, minimum: 1, maximum: 365, default: 30 } }
        - { name: limit, in: query, description: "Maximum rows to return (1-500, default 100). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Ranked contracts directory.
          content:
            application/json:
              example:
                data:
                  window_days: 30
                  since_ledger: 62797785
                  contracts:
                  - contract_id: CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
                    events: 84013660
                    last_ledger: 63316185
                    last_seen: '2026-07-03T22:38:55Z'
                  - contract_id: CB23WRDQWGSP6YPMY4UV5C4OW5CBTXKYN3XEATG7KJEZCXMJBYEHOUOV
                    events: 31700010
                    last_ledger: 63316185
                    last_seen: '2026-07-03T22:38:55Z'
                as_of: '2026-07-03T22:39:07.271202524Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      window_days: { type: integer }
                      since_ledger: { type: integer, format: int64 }
                      contracts:
                        type: array
                        items:
                          type: object
                          properties:
                            contract_id: { type: string }
                            events: { type: integer, format: int64 }
                            last_ledger: { type: integer, format: int64 }
                            last_seen: { type: string, format: date-time }
                            protocol: { type: string, description: Owning protocol when attributed; absent otherwise. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts/{contract_id}:
    get:
      tags: [explorer]
      summary: Per-contract recent on-chain event activity.
      description: |
        A contract's most-recent emitted events (newest first), keyset-paged
        with `?cursor=<opaque>` (echo back the response's `next_cursor`). The
        cursor is the composite `(ledger, op_index, event_index)` — a
        ledger-only cursor would silently drop the rest of a ledger in which a
        busy contract emits more than `limit` events. `next_cursor` is present
        only when a full page is returned. The SEP-41 transfer audit trail is at
        the sibling `/contracts/{contract_id}/transfers`.
      parameters:
        - { name: contract_id, in: path, required: true, description: C-strkey contract id., schema: { type: string } }
        - { name: limit, in: query, description: "Maximum rows to return (1-500, default 100). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: cursor, in: query, description: Opaque keyset cursor from a prior response's next_cursor., schema: { type: string } }
      responses:
        "200":
          description: Recent contract events.
          content:
            application/json:
              example:
                data:
                  contract_id: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                  events:
                  - ledger: 63316186
                    close_time: '2026-07-03T22:39:01Z'
                    tx_hash: f867993250a0cc767084ea5312de03786deaa3c8e0671b27e6724381696e4cf0
                    op_index: 0
                    event_index: 0
                    event_type: contract
                    topic_0: transfer
                  - ledger: 63316186
                    close_time: '2026-07-03T22:39:01Z'
                    tx_hash: 0982c7836389f9f876704cdac95fdaacd223482b0360c2b6a4039b3a4c0ea805
                    op_index: 0
                    event_index: 0
                    event_type: contract
                    topic_0: transfer
                  next_cursor: 63316186.0.0
                as_of: '2026-07-03T22:39:09.248840367Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contract_id: { type: string }
                      protocol: { type: string, description: Registry protocol this contract belongs to (blend, soroswap, …) when attribution is known; absent otherwise. }
                      events: { type: array, items: { $ref: "#/components/schemas/ContractEvent" } }
                      next_cursor: { type: string, description: Opaque cursor for the next (older) page; absent on the last page. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts/{contract_id}/wasm:
    get:
      tags: [explorer]
      summary: A contract's on-chain WASM — metadata, exports, disassembly.
      description: |
        The contract's deployed WebAssembly surfaced for the explorer's "see
        the code" view. Resolved on demand from the certified ClickHouse lake
        (ADR-0034): contract instance → wasm hash → `contract_code` bytes.

        `exports` is the contract's exported function table — the names are its
        public Soroban entry points (e.g. `swap`, `deposit`), parsed natively
        in Go (always present). `params`/`results` are the low-level wasm ABI
        value types (`i32`/`i64`/`f32`/`f64`), not the Rust signature.

        `wat` (WAT disassembly) and `decompiled` (wasm-decompile's C-like
        pseudocode — NOT reconstructed Rust) are BEST-EFFORT: present only when
        the wabt toolchain is installed on the server, otherwise empty with the
        reason in `source_note`. The response never 503s on missing tooling.

        The wasm for a content-addressed hash is immutable, so the response is
        cached for a day (`Cache-Control: public, max-age=86400`).

        404 when the contract's wasm can't be assembled from the captured
        `ledger_entry_changes` window — the contract-instance or contract-code
        entry (created at deploy time, often years ago) is outside the live
        capture window. This is a coverage limitation of the substrate, not an
        error.
      parameters:
        - { name: contract_id, in: path, required: true, description: C-strkey contract id., schema: { type: string } }
      responses:
        "200":
          description: The contract's wasm view.
          content:
            application/json:
              example:
                data:
                  contract_id: CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD
                  wasm_hash: a41fc53d6753b6c04eb15b021c55052366a4c8e0e21bc72700f461264ec1350e
                  size_bytes: 57328
                  exports:
                  - name: __constructor
                    params:
                    - i64
                    - i64
                    results:
                    - i64
                  - name: propose_admin
                    params:
                    - i64
                    results:
                    - i64
                  wat: "(module\n  (type (;0;) (func (param i64 i64) (result i64)))\n  (type (;1;) (func (param i64)…"
                  decompiled: 'export memory memory(initial: 17, max: 0);


                    global g_a:int = 1048576;

                    export global data_e…'
                  source_note: wasm resolved from the certified ClickHouse lake (contract instance → wasm hash → contract…
                as_of: '2026-07-03T22:40:51.773640137Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [contract_id, wasm_hash, size_bytes, exports, source_note]
                    properties:
                      contract_id: { type: string, description: C-strkey contract id (echoed). }
                      wasm_hash: { type: string, description: Hex sha256 of the wasm module (content address). }
                      size_bytes: { type: integer, description: Size of the wasm module in bytes. }
                      exports:
                        type: array
                        description: Exported functions — the contract's public entry points.
                        items:
                          type: object
                          required: [name, params, results]
                          properties:
                            name: { type: string, description: Exported function name. }
                            params: { type: array, items: { type: string }, description: "Wasm ABI param value types (i32/i64/f32/f64)." }
                            results: { type: array, items: { type: string }, description: Wasm ABI result value types. }
                      wat: { type: string, description: WAT disassembly; absent when wabt isn't installed on the server. }
                      decompiled: { type: string, description: "wasm-decompile pseudocode (C-like, NOT Rust); absent when wabt isn't installed." }
                      source_note: { type: string, description: Provenance + any degraded-stage explanation. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts/{contract_id}/interactions:
    get:
      tags: [explorer]
      summary: Contract interaction map — co-occurring contracts (cross-contract calls).
      description: |
        The contracts that emitted events in the same transactions as this one,
        ranked by shared-transaction count over a recent window (`?days=`,
        default 90, max 365). Co-occurrence within a transaction is a strong
        proxy for a cross-contract call: a Soroban invoke nests its sub-calls
        inside one `InvokeHostFunction` op, so the callee's events land in the
        caller's transaction. Each edge is tagged with the other contract's
        owning `protocol` where known. Powers the contract page's interaction
        graph.
      parameters:
        - { name: contract_id, in: path, required: true, description: C-strkey contract id., schema: { type: string } }
        - { name: days, in: query, description: Window size in days., schema: { type: integer, minimum: 1, maximum: 365, default: 90 } }
        - { name: limit, in: query, description: "Maximum rows to return (1-200, default 50). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Ranked interaction edges.
          content:
            application/json:
              example:
                data:
                  contract_id: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                  window_days: 90
                  since_ledger: 61760987
                  interactions:
                  - contract_id: CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
                    shared_txs: 49067
                  - contract_id: CARIFTQ64I7RUTN6VAD5CAXJGU5EQRTI6KBZPWD65CO33IQPHTHBSVNS
                    shared_txs: 16207
                as_of: '2026-07-03T22:39:12.058161999Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contract_id: { type: string }
                      window_days: { type: integer }
                      since_ledger: { type: integer, format: int64 }
                      interactions:
                        type: array
                        items:
                          type: object
                          properties:
                            contract_id: { type: string }
                            shared_txs: { type: integer, format: int64 }
                            protocol: { type: string, description: Owning protocol when attributed; absent otherwise. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts/{contract_id}/code-history:
    get:
      tags: [explorer]
      summary: Contract code/upgrade history — WASM-hash timeline.
      description: |
        The contract's "change over time": each distinct WASM executable its
        instance has pointed at, in chronological order, so an in-place
        `update_contract` upgrade surfaces as a new version (ledger +
        close time + wasm hash). Reconstructed from the captured
        `ledger_entry_changes` instance entries; empty when the instance isn't
        in the captured window (fills with the Phase-C backfill).
      parameters:
        - { name: contract_id, in: path, required: true, description: C-strkey contract id., schema: { type: string } }
      responses:
        "200":
          description: Chronological WASM-hash versions.
          content:
            application/json:
              example:
                data:
                  contract_id: CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                  versions: []
                as_of: '2026-07-03T22:39:13.312316848Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contract_id: { type: string }
                      versions:
                        type: array
                        items:
                          type: object
                          properties:
                            ledger: { type: integer, format: int64 }
                            close_time: { type: string, format: date-time }
                            wasm_hash: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts:
    get:
      tags: [explorer]
      summary: Accounts directory — ranked by total USD wealth.
      description: |
        The accounts directory, ranked by the total USD value of their
        holdings: native XLM plus every trustline asset we hold a verified USD
        price for (the verified-currency catalogue; stablecoins resolve through
        the fiat proxy). Computed in one pass over the current-state projection
        (latest `ledger_entry_changes` per key, ADR-0038 Phase C).

        `priced_assets` is the count of assets that contributed (how many of the
        catalogue had a live price at request time). `usd_value` is a decimal
        string (2dp). Coverage tracks the entry-change capture + Phase-C
        backfill — an account ranks once its balances are captured.
      parameters:
        - { name: limit, in: query, description: "Maximum rows to return (1-500, default 100). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: Accounts ranked by USD wealth, descending.
          content:
            application/json:
              example:
                data:
                  priced_assets: 11
                  accounts:
                  - account_id: GALAXYVOIDAOPZTDLHILAJQKCVVFMD4IKLXLSZV5YHO7VY74IWZILUTO
                    usd_value: '11319540791.76'
                    locked: true
                  - account_id: GDUY7J7A33TQWOSOQGDO776GGLM3UQERL4J3SPT56F6YS4ID7MLDERI4
                    usd_value: '838665433.46'
                as_of: '2026-07-03T22:39:22.213002912Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      priced_assets: { type: integer, description: Number of assets that contributed a price. }
                      accounts:
                        type: array
                        items:
                          type: object
                          properties:
                            account_id: { type: string }
                            usd_value: { type: string, description: Total holdings value in USD (decimal string). }
                            locked: { type: boolean, description: Provably unspendable burn address — master weight 0, all thresholds 0, no signers. The balance is real; no key can move it. }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}:
    get:
      tags: [explorer]
      summary: Account state — balance, signers, thresholds, trustlines, offers.
      description: |
        The account's current on-chain state, reconstructed from the certified
        lake (latest `ledger_entry_changes` per key, ADR-0038 Phase C): native
        XLM balance, sequence number, sub-entry count, flags, home domain,
        signer set + thresholds, plus its live trustlines (per-asset balances +
        limits) and open offers.

        `exists:false` (with HTTP 200, not 404) when the account has no live
        AccountEntry in the captured ledger window — never created, merged away,
        or its create predates live capture (resolves once the Phase-C backfill
        lands). Balances are strings (ADR-0003).
      parameters:
        - { name: g_strkey, in: path, required: true, description: Account G-strkey., schema: { type: string } }
      responses:
        "200":
          description: Account state (or `exists:false`).
          content:
            application/json:
              example:
                data:
                  account_id: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  exists: true
                  balance: '235182317613'
                  seq_num: '144373126631784461'
                  num_subentries: 6
                  flags: 2
                  home_domain: circle.com
                  thresholds:
                    master: 0
                    low: 2
                    med: 2
                    high: 2
                  signers:
                  - key: GAUKFO2NIYEFO773KJZKLPSPYNQ6M7INPEAIQIJCIH7EEVP2KSVQWGH4
                    weight: 1
                  - key: GAXFRO4MH6FSBJMNECVZJ6R3ZXANI7CFCMVN47IDNRQDHII3J5HTOZGB
                    weight: 1
                  trustlines:
                  - asset: USDCAllow-GDIEKKIQWMIZ4LD3RP3ABPN7X5KEAEWYMR634BRHB7EULIMEVREWLF3G
                    balance: '77129744523269078'
                    limit: '9223372036854775807'
                    flags: 1
                  offers:
                  - offer_id: 426903336
                    selling: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                    buying: USDCAllow-GDIEKKIQWMIZ4LD3RP3ABPN7X5KEAEWYMR634BRHB7EULIMEVREWLF3G
                    amount: '9146242292331506729'
                    price_n: 1
                    price_d: 1
                  last_modified_ledger: 63314771
                as_of: '2026-07-03T22:39:25.722776013Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      account_id: { type: string }
                      exists: { type: boolean }
                      balance: { type: string, description: Native XLM balance in stroops. }
                      seq_num: { type: string }
                      num_subentries: { type: integer }
                      flags: { type: integer }
                      home_domain: { type: string }
                      thresholds:
                        type: object
                        properties:
                          master: { type: integer }
                          low: { type: integer }
                          med: { type: integer }
                          high: { type: integer }
                      signers:
                        type: array
                        items:
                          type: object
                          properties:
                            key: { type: string }
                            weight: { type: integer }
                      trustlines:
                        type: array
                        items:
                          type: object
                          properties:
                            asset: { type: string }
                            balance: { type: string }
                            limit: { type: string }
                            flags: { type: integer }
                      offers:
                        type: array
                        items:
                          type: object
                          properties:
                            offer_id: { type: integer, format: int64 }
                            selling: { type: string }
                            buying: { type: string }
                            amount: { type: string }
                            price_n: { type: integer }
                            price_d: { type: integer }
                      last_modified_ledger: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}/transactions:
    get:
      tags: [explorer]
      summary: Transactions involving an account (sourced + incoming), newest first.
      description: |
        The transactions INVOLVING this account, newest first, keyset-paged
        with `?cursor=<opaque>` (echo back `next_cursor`). The cursor is the
        composite `(ledger, tx_index)`.

        SCOPE: `scope: "all"` (ADR-0038 Phase B) — both transactions the
        account SOURCED (source/fee-payer) AND those where it's a non-source
        PARTICIPANT in any operation (payment destination, trustor, merge
        target, clawback victim, …), via the operation-participant index.
        Incoming coverage tracks the participant-index capture + historical
        backfill — a transaction whose only link to the account predates
        participant capture surfaces once the re-derive lands.
      parameters:
        - { name: g_strkey, in: path, required: true, description: G-strkey account id., schema: { type: string } }
        - { name: limit, in: query, description: "Maximum rows to return (1-200, default 50). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: cursor, in: query, description: Opaque keyset cursor from a prior response's next_cursor., schema: { type: string } }
      responses:
        "200":
          description: Transactions involving the account (sourced + incoming).
          content:
            application/json:
              example:
                data:
                  account: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  transactions:
                  - hash: be8ac09cf011950987ae7c17badec336ccf24782a03f5573b1f982cb44c98f36
                    ledger: 63315192
                    close_time: '2026-07-03T21:02:29Z'
                    index: 7
                    source_account: GDSQAEHJLE2ZZMQZ47YWLP3O2HVPYQ4QCFWTHUKMKF6RIX2ZJJDDMK4N
                    fee_charged: 200
                    max_fee: 13935
                    operation_count: 1
                    successful: true
                    result_code: 1
                    memo_type: text
                    memo: internal
                  next_cursor: '63315192.7'
                  scope: all
                as_of: '2026-07-03T22:39:31.01258567Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountTransactions" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}/operations:
    get:
      tags: [explorer]
      summary: Operations involving an account (sourced + incoming), newest first.
      description: |
        The operations INVOLVING this account, decoded (human-readable
        fields), newest first, keyset-paged with `?cursor=<opaque>` (echo back
        `next_cursor`). The cursor is the composite `(ledger, tx_index,
        op_index)`.

        SCOPE: `scope: "all"` (ADR-0038 Phase B) — operations the account
        SOURCED plus those where it's a non-source PARTICIPANT — see the
        transactions endpoint above for the coverage/backfill note.
      parameters:
        - { name: g_strkey, in: path, required: true, description: G-strkey account id., schema: { type: string } }
        - { name: limit, in: query, description: "Maximum rows to return (1-200, default 50). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: cursor, in: query, description: Opaque keyset cursor from a prior response's next_cursor., schema: { type: string } }
      responses:
        "200":
          description: Sourced operations for the account.
          content:
            application/json:
              example:
                data:
                  account: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  operations:
                  - ledger: 63315192
                    close_time: '2026-07-03T21:02:29Z'
                    tx_hash: be8ac09cf011950987ae7c17badec336ccf24782a03f5573b1f982cb44c98f36
                    tx_index: 7
                    op_index: 0
                    type: payment
                    source_account: GDSQAEHJLE2ZZMQZ47YWLP3O2HVPYQ4QCFWTHUKMKF6RIX2ZJJDDMK4N
                    fields:
                      amount: '1000000000000'
                      asset: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                      destination: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  next_cursor: 63315192.7.0
                  scope: all
                as_of: '2026-07-03T22:39:45.671725025Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountOperations" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /search:
    get:
      tags: [explorer]
      summary: Classify a query (tx / ledger / account / contract / asset).
      description: |
        Single-box search: detects the kind of `q` by its shape and returns the
        canonical detail endpoint to route to. Pure classification (no lake read).
      parameters:
        - { name: q, in: query, required: true, description: Tx hash, ledger seq, G-account, C-contract, or asset id., schema: { type: string } }
      responses:
        "200":
          description: Classified query.
          content:
            application/json:
              example:
                data:
                  query: '63316166'
                  kind: ledger
                  canonical: '63316166'
                  href: /v1/ledgers/63316166
                  supported: true
                as_of: '2026-07-03T22:41:59.42624588Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      query: { type: string }
                      kind: { type: string, enum: [transaction, ledger, account, contract, asset, unknown] }
                      canonical: { type: string }
                      href: { type: string }
                      supported: { type: boolean }
                      note: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }

components:
  securitySchemes:
    APIKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "sip_* (Stellar Index key)"
      description: |
        API key sent as a bearer token:
        `Authorization: Bearer sip_…`. Create and manage keys from
        the customer dashboard at
        https://stellarindex.io/dashboard/keys (or bootstrap one via
        `POST /v1/signup`). The same header also accepts SEP-10 JWTs
        from `/v1/auth/sep10/token`. Anonymous access (no header) is
        allowed on the public read endpoints at a lower per-IP
        rate-limit tier — keys raise the limit and unlock the
        /account/* self-service surface.
    SessionCookie:
      type: apiKey
      in: cookie
      name: stellarindex_session
      description: |
        Browser session cookie minted by the magic-link /
        6-digit-code sign-in flows (`/v1/auth/callback`,
        `/v1/auth/verify-code`). HttpOnly; scoped to the dashboard
        origin. Gates the `/v1/dashboard/*` management surface —
        API keys are NOT accepted there. Cleared by
        `POST /v1/auth/logout`.

  parameters:
    AssetIdPath:
      name: asset_id
      in: path
      required: true
      schema: { type: string, example: native }
      example: native
      description: |
        Canonical asset identifier. One of `native`, `<code>-<issuer>`,
        `<code>:<issuer>` (alias), or `<contract_id>`. Strkeys
        validated per SEP-23. The handler is strict — short symbols
        like `XLM` or `USDC` are NOT accepted here; use `native` or
        the full `<code>-<G…>` form.
    AssetQuery:
      name: asset
      in: query
      required: true
      schema: { type: string, example: native }
      example: native
      description: |
        Canonical asset identifier — matches the `asset_id` on
        response bodies. Query-parameter form is the shorter `asset`
        per the handler implementations (/v1/price, /v1/oracle/latest).
        Strict canonical form only — `XLM` / `USDC` are rejected;
        use `native` for XLM and the full `<code>-<G…>` strkey for
        credit assets.
    # Optional quote — defaults to fiat:USD when omitted. Used by the
    # price family (/price, /price/tip, /price/batch, /chart,
    # /observations) where handlePrice et al. fall back to
    # defaultPriceQuote.
    Quote:
      name: quote
      in: query
      schema: { type: string, default: "fiat:USD", example: "fiat:USD" }
      example: "fiat:USD"
      description: |
        Quote-side asset. Either a canonical asset identifier (`native`,
        `<code>-<issuer>`, contract ID) for crypto-quoted pairs, or
        the `fiat:<ISO-4217>` form for fiat quotes (e.g. `fiat:USD`,
        `fiat:EUR`). Default `fiat:USD`.

    # Required quote — the base/quote pair family (/history, /ohlc,
    # /vwap, /twap) goes through parseBaseQuote (history.go), which
    # 400s when `quote` is omitted rather than defaulting it. There is
    # NO default on this surface; the parameter is mandatory.
    QuoteRequired:
      name: quote
      in: query
      required: true
      schema: { type: string, example: "fiat:USD" }
      example: "fiat:USD"
      description: |
        Quote-side asset (REQUIRED on this endpoint). Either a
        canonical asset identifier (`native`, `<code>-<issuer>`,
        contract ID) or the `fiat:<ISO-4217>` form (e.g. `fiat:USD`,
        `fiat:EUR`). Unlike the `/price` family, this endpoint does
        NOT default the quote — omitting it returns 400.
    Timeframe:
      name: timeframe
      in: query
      description: |
        Rolling lookback window anchored at now — `24h` means
        "the last 24 hours", `all` means "since first observation".
        Shorthand alternative to explicit `from`/`to` bounds on the
        chart/series surfaces. Default `24h`.
      schema:
        type: string
        enum: [1h, 24h, 1w, 1mo, 1y, all]
        default: 24h
    Granularity:
      name: granularity
      in: query
      description: |
        Bucket width for the returned series (`1m` = 1 minute …
        `1mo` = 1 month). When omitted the server picks a sensible
        width for the requested timeframe (e.g. `1m` for `1h`,
        `1h` for `1w`) so the series stays a few hundred points.
      schema:
        type: string
        enum: [1m, 15m, 1h, 4h, 1d, 1w, 1mo]
    Base:
      name: base
      in: query
      required: true
      schema:
        type: string
        example: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
      example: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
      description: |
        Canonical asset identifier for the base side of a pair.
        Strict canonical form only — `USDC` / `XLM` are rejected;
        the full `<code>-<G…>` strkey is required, or `native` for
        XLM. The default example resolves to Centre's USDC issuance,
        which is the most-traded base on Stellar.
    From:
      name: from
      in: query
      description: |
        Window start (inclusive), RFC 3339 UTC — e.g.
        `2026-06-01T00:00:00Z`. Windows are half-open `[from, to)`.
        When omitted the endpoint applies its own default lookback
        from `to` (documented per endpoint). Must be before `to`
        or the request 400s.
      schema: { type: string, format: date-time }
    To:
      name: to
      in: query
      description: |
        Window end (exclusive), RFC 3339 UTC. Defaults to now.
        Windows are half-open `[from, to)` — a trade exactly at
        `to` is excluded.
      schema: { type: string, format: date-time }
    PriceType:
      name: price_type
      in: query
      description: |
        Series type. `vwap` (default) returns the price series.
        `twap` is reserved for forward compatibility (currently
        400). `market_cap` returns a USD-denominated market-cap
        series. For fiat:* base assets it is M2 (verified-currency
        catalogue) × daily FX rate (fx_quotes). For on-chain
        (native / classic / Soroban) base assets it is the daily USD
        price × daily circulating supply (the `supply_1d` continuous
        aggregate, forward-filled). Off-chain `crypto:*` reference
        assets (BTC/ETH/…) have no on-chain supply we publish, so
        they return an empty series.
      schema:
        type: string
        enum: [vwap, twap, market_cap]
        default: vwap
    # TypeFilter / CodeFilter / IssuerFilter are forward-documented
    # for the planned /operations filtering surface — part of the
    # published parameter vocabulary but not yet referenced by an
    # operation. Intentional: do NOT delete as "orphans" (they have
    # been mistaken for dead params before — see commit 8ea2b9ad).
    TypeFilter:
      name: type
      in: query
      description: |
        Filter rows by asset class: `native` (XLM), `classic`
        (issued CODE-ISSUER assets), `soroban` (SEP-41 contract
        tokens), `fiat` (reference currencies). `any` (the
        default) disables the filter.
      schema:
        type: string
        enum: [native, classic, soroban, fiat, any]
        default: any
    CodeFilter:
      name: code
      in: query
      description: |
        Filter to rows whose asset code matches exactly
        (case-sensitive, e.g. `USDC`). Codes are not unique on
        Stellar — combine with `issuer` to pin one asset.
      schema: { type: string }
    IssuerFilter:
      name: issuer
      in: query
      description: |
        Filter to assets issued by this account (G-strkey,
        SEP-23). Combine with `code` to select a single
        classic asset.
      schema: { type: string }
    Cursor:
      name: cursor
      in: query
      description: |
        Opaque pagination token echoed from a prior response's
        `pagination.next`. Pass it verbatim — it is a base64url-encoded
        blob whose internal shape is an implementation detail and
        changes without notice. Clients MUST NOT parse, decode, or
        construct cursors by hand.

        A cursor is stable across retries but not across schema
        changes; treat it as short-lived (minutes, not days). Empty
        means "start from the beginning".
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: |
        Maximum rows per page (1-500, default 100). Values above
        the cap return 400 rather than being silently clamped.
        Page onward with `cursor` where the endpoint supports it.
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }

  responses:
    # Shared non-2xx shapes — every error path the server produces
    # uses the RFC 9457 Problem payload. New endpoint entries SHOULD
    # reference these (via `default:` or explicit status codes)
    # rather than declaring inline schemas, so the wire contract
    # stays consistent.
    NotFound:
      description: Not found.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: "https://api.stellarindex.io/errors/asset-not-found"
            title: "Asset not found"
            status: 404
            detail: "No trades observed for asset_id=XYZ-G..."
            instance: "/v1/assets/XYZ-GA5Z"
            request_id: "9be8c8cc17163dc7e40b07a55beee744"
    BadRequest:
      description: Client-supplied parameters didn't validate.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: "https://api.stellarindex.io/errors/missing-asset"
            title: "Missing asset parameter"
            status: 400
            detail: "asset query parameter is required"
            instance: "/v1/price"
            request_id: "499673a2b4d5f84bdbfa18bcfdc65bbe"
    Unauthorized:
      description: No valid credential — an API key, SEP-10 token, or session is required.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: "https://api.stellarindex.io/errors/unauthorized"
            title: "Authentication required"
            status: 401
            detail: "/v1/account/me requires a magic-link session, API key, or SEP-10 token"
            instance: "/v1/account/me"
            request_id: "2f7c1a9e4b8d3c6a5e0f1b2d4c8a9e7f"
    RateLimited:
      description: Rate-limit quota exhausted. See `Retry-After` header.
      headers:
        Retry-After:
          schema: { type: integer, minimum: 1 }
          description: Seconds until the caller can retry.
        X-RateLimit-Limit:
          schema: { type: integer }
          description: >-
            Request budget for the caller's tier in the current
            fixed window (per-key override applied when one is
            set).
        X-RateLimit-Remaining:
          schema: { type: integer }
          description: >-
            Requests left in the current window AFTER this request.
            0 means the next request in this window will be 429'd.
        X-RateLimit-Reset:
          schema: { type: integer }
          description: >-
            Unix-epoch seconds at which the current fixed-window
            bucket resets. Clients compute
            `seconds_until_reset = X-RateLimit-Reset - now` to back
            off proactively (GitHub / Twitter header semantics).
            All three X-RateLimit-* headers ride every response the
            rate limiter evaluates — 2xx included, not just 429s —
            but are absent on deployments running without a rate
            limiter and on requests served fail-open during a Redis
            outage.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: "https://api.stellarindex.io/errors/rate-limited"
            title: "Rate limit exceeded"
            status: 429
            detail: "anonymous tier limited to 60 requests per minute; retry in 23 seconds"
            instance: "/v1/assets"
            request_id: "feb6615b1a38211b4835c0fefcd94ede"
    InternalError:
      description: Internal error. Body includes the request_id for correlation.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: "https://api.stellarindex.io/errors/internal"
            title: "Internal error"
            status: 500
            detail: "see X-Request-ID in server logs"
            instance: "/v1/oracle/latest"
            request_id: "e0fca799b0b6d066ed6c928b5cb0d5e6"
    ServiceUnavailable:
      description: Server is degraded (dependency outage, startup, shutdown).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: "https://api.stellarindex.io/errors/account-store-unavailable"
            title: "Account store not configured"
            status: 503
            detail: "this deployment has no AccountStore wired — typically because Redis is unavailable"
            instance: "/v1/account/keys"
            request_id: "70c8017d79651070fd16c2c9f065d846"

  schemas:
    # ───────────────── Network explorer (ADR-0038) ─────────────────
    Ledger:
      type: object
      description: A ledger header from the certified lake.
      properties:
        sequence: { type: integer }
        close_time: { type: string, format: date-time }
        hash: { type: string, description: Hex-encoded ledger hash. }
        prev_hash: { type: string }
        protocol_version: { type: integer }
        tx_count: { type: integer }
        op_count: { type: integer }
        soroban_event_count: { type: integer }
        total_coins: { type: string, description: XLM stroops as a string (exceeds 2^53). }
        fee_pool: { type: string }
        base_fee: { type: integer }
        base_reserve: { type: integer }
    TxSummary:
      type: object
      description: Transaction summary (in ledger + tx listings).
      properties:
        hash: { type: string }
        ledger: { type: integer }
        close_time: { type: string, format: date-time }
        index: { type: integer }
        source_account: { type: string }
        fee_charged: { type: integer }
        max_fee: { type: integer }
        operation_count: { type: integer }
        successful: { type: boolean }
        result_code: { type: integer }
        memo_type: { type: string, description: "Normalised: none|text|id|hash|return." }
        memo: { type: string }
    Operation:
      type: object
      description: An operation decoded from XDR into clean JSON.
      properties:
        ledger: { type: integer }
        close_time: { type: string, format: date-time }
        tx_hash: { type: string }
        tx_index: { type: integer }
        op_index: { type: integer }
        type: { type: string, description: snake_case op type (e.g. payment, manage_sell_offer). }
        source_account: { type: string }
        fields: { type: object, additionalProperties: true, description: Decoded operation fields (amounts are strings, ADR-0003). }
        raw_xdr: { type: string, description: Base64 body, present only for op types not yet field-decoded. }
        result_code: { type: integer, description: Present only in the per-transaction view. }
    TxDetail:
      allOf:
        - { $ref: "#/components/schemas/TxSummary" }
        - type: object
          properties:
            operations: { type: array, items: { $ref: "#/components/schemas/Operation" } }
            events: { type: array, items: { $ref: "#/components/schemas/ContractEvent" } }
    ContractEvent:
      type: object
      description: A contract event (tx-detail + contract-activity views).
      properties:
        contract_id: { type: string, description: "Emitting contract (C-strkey). Present on per-tx event rows (explorer_tx.go TxEventView)." }
        ledger: { type: integer }
        close_time: { type: string, format: date-time }
        tx_hash: { type: string }
        op_index: { type: integer }
        event_index: { type: integer }
        event_type: { type: string }
        topic_0: { type: string }
        topics: { type: array, items: { type: string }, description: "Human-readable renderings of topics[1:] (topic_0 carries the symbol). Display format — lossy by design; addresses render as strkeys, i128 amounts as integers." }
        data: { type: string, description: Human-readable rendering of the event data payload (display format, truncated for large values). }
    AccountTransactions:
      type: object
      description: |
        Transactions involving an account (newest first), with an opaque
        composite keyset cursor. `scope` is "all" (ADR-0038 Phase B): sourced
        plus incoming/participant. Incoming coverage tracks participant-index
        capture + backfill.
      properties:
        account: { type: string, description: The G-strkey this listing is for. }
        transactions: { type: array, items: { $ref: "#/components/schemas/TxSummary" } }
        next_cursor: { type: string, description: "Opaque composite cursor (ledger.tx_index) for the next older page; absent on the last page." }
        scope: { type: string, enum: [all], description: "Activity scope. \"all\" = the account sourced the tx OR is a non-source participant in one of its operations." }
    AccountOperations:
      type: object
      description: |
        Operations involving an account, decoded (newest first), with an opaque
        composite keyset cursor. `scope` is "all" (ADR-0038 Phase B): sourced
        plus incoming/participant.
      properties:
        account: { type: string, description: The G-strkey this listing is for. }
        operations: { type: array, items: { $ref: "#/components/schemas/Operation" } }
        next_cursor: { type: string, description: "Opaque composite cursor (ledger.tx_index.op_index) for the next older page; absent on the last page." }
        scope: { type: string, enum: [all], description: "Activity scope. \"all\" = the account sourced the op OR is a non-source participant in it." }
    DashboardKey:
      type: object
      description: |
        Dashboard view of an API key. Plaintext is NEVER on this
        shape — that's only on `CreateKeyResponse.plaintext`,
        returned exactly once at creation time.
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        key_prefix:
          type: string
          description: First 12 chars of the plaintext (`sip_<8hex>`).
        tier:
          type: string
          enum: [apikey, partner, operator]
        rate_limit_per_min: { type: integer }
        monthly_quota: { type: integer, format: int64 }
        usage_alert_threshold_pct: { type: integer }
        ip_allowlist:
          type: array
          items: { type: string }
        referer_allowlist:
          type: array
          items: { type: string }
        expires_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time }
        revoked_reason: { type: string }
        last_used_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
      required: [id, name, key_prefix, tier, rate_limit_per_min, created_at]

    CreateKeyRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 200
          description: Customer-facing label.
        description:
          type: string
          maxLength: 2000
        rate_limit_per_min:
          type: integer
          minimum: 1
          maximum: 100000
          description: |
            Requested per-minute rate-limit budget for this key.
            **Silently clamped to the account's tier ceiling** —
            see `platform.Tier.MaxRateLimitPerMin` in code:

              - Free:       60/min  (key parity with anon-tier)
              - Starter:    1000/min
              - Pro:        10000/min
              - Business:   60000/min
              - Enterprise: 100000/min

            A request of 100000 on a Free account persists 60.
            The `maximum: 100000` in this schema is the hard ceiling
            for any tier; the effective cap is whichever is lower.
            F-1256 (codex audit-2026-05-12).
        monthly_quota:
          type: integer
          format: int64
        ip_allowlist:
          type: array
          items: { type: string }
          description: |
            CIDR (`203.0.113.0/24`) or bare IP (auto-promoted to
            /32 v4 or /128 v6).
        referer_allowlist:
          type: array
          items: { type: string }
        expires_at:
          type: string
          format: date-time
          description: RFC 3339; must be in the future.
        usage_alert_threshold_pct:
          type: integer
          minimum: 1
          maximum: 100
      required: [name]

    CreateKeyResponse:
      type: object
      properties:
        plaintext:
          type: string
          description: |
            The full plaintext key. Returned exactly once; never
            re-displayed. Customers store this in their secret
            manager.
        key: { $ref: "#/components/schemas/DashboardKey" }
      required: [plaintext, key]

    DashboardWebhook:
      type: object
      description: |
        Customer-registered webhook endpoint backing the
        /v1/dashboard/webhooks surface. SecretHash is intentionally
        omitted — the plaintext signing secret is returned ONCE at
        create time + never again.
      properties:
        id:         { type: string, format: uuid }
        name:       { type: string, description: "Operator-friendly label, 1–200 chars." }
        url:        { type: string, description: "HTTPS endpoint. Worker POSTs JSON with HMAC-SHA-256 signature in X-StellarIndex-Signature." }
        events:
          type: array
          description: |
            Closed enum of event types the customer subscribed to.
            Per-event JSON body shapes documented at
            `IncidentWebhookPayload` (`incident.sev1` / `incident.resolved`),
            `AnomalyFreezeWebhookPayload` (`anomaly.freeze`), and
            `DivergenceFiringWebhookPayload` (`divergence.firing`).
          items:
            type: string
            enum:
              - incident.sev1
              - incident.resolved
              - anomaly.freeze
              - divergence.firing
        enabled:    { type: boolean, description: "When false, worker skips deliveries (silently terminates pending rows)." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      required: [id, name, url, events, enabled, created_at, updated_at]

    CreateWebhookRequest:
      type: object
      properties:
        name:    { type: string, minLength: 1, maxLength: 200 }
        url:     { type: string, description: "Must start with https://." }
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [incident.sev1, incident.resolved, anomaly.freeze, divergence.firing]
        enabled:
          type: boolean
          description: "Defaults true when absent."
      required: [name, url, events]

    CreateWebhookResponse:
      type: object
      properties:
        webhook: { $ref: "#/components/schemas/DashboardWebhook" }
        secret:
          type: string
          description: |
            HMAC-SHA-256 signing key plaintext. Returned exactly
            once. Store server-side + use it to verify the
            X-StellarIndex-Signature header on inbound webhook
            POSTs. Format: `wsec_<64 hex chars>`.
      required: [webhook, secret]

    UpdateWebhookRequest:
      type: object
      description: |
        PATCH body — any subset of fields. Omitted fields keep
        their current value.
      properties:
        name:    { type: string, minLength: 1, maxLength: 200 }
        url:     { type: string }
        events:
          type: array
          items:
            type: string
            enum: [incident.sev1, incident.resolved, anomaly.freeze, divergence.firing]
        enabled: { type: boolean }

    WebhookDeliveryDTO:
      type: object
      description: |
        One delivery attempt against a customer webhook. The
        worker fans out one row per (webhook, event); a single
        attempt records its status + retry plan here.
      properties:
        id:                   { type: string, format: uuid }
        event_type:           { type: string, enum: [incident.sev1, incident.resolved, anomaly.freeze, divergence.firing] }
        attempt_count:        { type: integer, minimum: 0 }
        next_attempt_at:      { type: string, format: date-time, nullable: true, description: "When the worker will retry. Null/zero when terminal (delivered OR retry budget exhausted)." }
        delivered_at:         { type: string, format: date-time, nullable: true }
        last_error:           { type: string, nullable: true }
        last_response_status: { type: integer, nullable: true, description: "HTTP status of the most-recent attempt." }
        created_at:           { type: string, format: date-time }
      required: [id, event_type, attempt_count, created_at]

    # ──────────────────────────────────────────────────────────
    # Customer-webhook payload schemas
    #
    # Schemas for the JSON body customers receive when one of the
    # four event types fires. The worker POSTs these payloads to
    # the customer's configured URL with an HMAC-SHA-256
    # signature in `X-StellarIndex-Signature` (verify via the
    # `wsec_<64 hex>` secret returned exactly once at create time).
    #
    # Every payload carries `event` (the event-type discriminator)
    # and `at` (RFC 3339 nanosecond timestamp). The remaining
    # fields are event-specific.
    # ──────────────────────────────────────────────────────────

    IncidentWebhookPayload:
      type: object
      description: |
        Body of an `incident.sev1` or `incident.resolved` webhook
        delivery. Fired by the operator command
        `stellarindex-ops emit-incident` from the Markdown corpus
        at `internal/incidents/data/<YYYY-MM-DD>-<slug>.md`.

        `incident.sev1` fires when an operator publishes a new
        SEV-1 incident; `incident.resolved` fires when the same
        incident is marked resolved (the second fire-up carries
        `resolved_at` and may carry `postmortem`).
      properties:
        event:                { type: string, enum: [incident.sev1, incident.resolved] }
        slug:                 { type: string, description: "Stable identifier matching the incident's Markdown filename (without the `.md` suffix)." }
        title:                { type: string, description: "Customer-facing one-line summary." }
        severity:             { type: string, enum: [SEV-1, SEV-2, SEV-3] }
        status:               { type: string, enum: [investigating, identified, monitoring, resolved] }
        started_at:           { type: string, format: date-time, description: "When the incident was first detected." }
        resolved_at:          { type: string, format: date-time, nullable: true, description: "Only present on `incident.resolved` deliveries." }
        affected_components:
          type: array
          items: { type: string }
          description: "One or more impacted surfaces — names match the status-page component set (api / indexer / aggregator / storage)."
        postmortem:           { type: string, nullable: true, description: "URL or path of the public postmortem, when published." }
        at:                   { type: string, format: date-time, description: "When this delivery was generated (server time, RFC 3339 nanosecond)." }
      required: [event, slug, title, severity, status, started_at, affected_components, at]

    AnomalyFreezeWebhookPayload:
      type: object
      description: |
        Body of an `anomaly.freeze` webhook delivery. Fired by the
        aggregator when the freeze policy engages on a pair —
        meaning the served price is intentionally pinned to its
        last-good value because the live signal failed an anomaly
        check (manipulation-resistance, sudden divergence, etc.).
        See ADR-0019 for the freeze policy.
      properties:
        event:                { type: string, enum: [anomaly.freeze] }
        asset:                { type: string, description: "Canonical asset_id of the base asset (e.g. `native`, `credit:USDC:G…`, `C…` for Soroban)." }
        quote:                { type: string, description: "Canonical asset_id of the quote asset (e.g. `fiat:USD`)." }
        frozen_value:         { type: string, description: "The price value we pinned the pair to during the freeze (decimal as a string to preserve precision)." }
        reason:
          type: string
          description: "Why the freeze engaged. Values are stable; new reasons may be added in future versions."
        at:                   { type: string, format: date-time, description: "When the freeze was engaged (server time, RFC 3339 nanosecond)." }
      required: [event, asset, quote, frozen_value, reason, at]

    DivergenceFiringWebhookPayload:
      type: object
      description: |
        Body of a `divergence.firing` webhook delivery. Fired by
        the divergence service when our aggregated price for a pair
        differs from a configured external reference (CoinGecko,
        Chainlink, etc.) by more than the configured threshold for
        the configured number of consecutive checks.
      properties:
        event:                { type: string, enum: [divergence.firing] }
        pair:                 { type: string, description: "Canonical pair string (e.g. `native/fiat:USD`, `crypto:BTC/fiat:USD`)." }
        our_price:            { type: string, description: "Our aggregated price for the pair at the check time (decimal-as-string)." }
        median:               { type: string, description: "Median of the available external references for the same pair (decimal-as-string)." }
        divergence_pct:       { type: number, format: double, description: "Percent divergence between `our_price` and `median`." }
        success_count:        { type: integer, description: "How many external references successfully responded to the check." }
        sources:
          type: array
          items: { type: string }
          description: "Names of the external reference sources that contributed to the median."
        at:                   { type: string, format: date-time, description: "When the divergence check ran (server time, RFC 3339 nanosecond)." }
      required: [event, pair, our_price, median, divergence_pct, success_count, sources, at]

    # Base envelope pieces
    Flags:
      type: object
      description: |
        Advisory quality markers. See docs/architecture/ha-plan.md §9
        and ADR-0019 (anomaly detection / freeze policy).

        - `stale` — response is below this surface's documented
          baseline contract.
        - `reduced_redundancy` — cross-region redundancy is degraded
          (R2/R3 set this when R1's last completeness run is stale
          per ADR-0017).
        - `triangulated` — set when the served price was derived
          via cross-pair multiplication (e.g. XLM/EUR computed from
          XLM/USD × USD/EUR) rather than direct trade observation.
          The aggregator's triangulation pass writes the implied
          VWAP into the same Redis key with a `:provenance` marker;
          `/v1/price` reads that marker and forwards the flag.
        - `divergence_warning` — anomaly check or cross-reference
          observed a meaningful divergence; treat with caution.
        - `frozen` — anomaly detection refused to publish the new
          bucket; this response carries the previous bucket's
          last-known-good value (ADR-0019). Only fires on `/v1/price`;
          tip + observations surfaces ignore freeze.
        - `single_source` — the bucket had only one contributing
          source. When `frozen=true` this is forced true (an LKG
          fallback is by definition single-sourced). Informational;
          combined with `frozen` this is the manipulation signature.
        - `unverified_ticker_collision` — fires on `/v1/assets/{id}`
          when the asset's code matches a verified currency's
          Stellar ticker but the issuer doesn't. The matching
          `unverified_warning` body on the AssetDetail carries
          the verified-currency pointer (R-018 Phase 1.1).
      properties:
        stale:                       { type: boolean, default: false }
        reduced_redundancy:          { type: boolean, default: false }
        triangulated:                { type: boolean, default: false }
        divergence_warning:          { type: boolean, default: false }
        divergence_checked:
          type: boolean
          default: false
          description: >
            True only when a live cross-reference divergence check ran (at least
            one responding reference). When false, `divergence_warning` is NOT
            meaningful — the check is blind (references dark, or no record yet),
            so a `false` warning must not be read as "prices agree" (CS-087).
        frozen:                      { type: boolean, default: false }
        single_source:               { type: boolean, default: false }
        unverified_ticker_collision: { type: boolean, default: false }
      required: [stale, reduced_redundancy, triangulated, divergence_warning]

    Pagination:
      type: object
      description: |
        Present on list endpoints when more rows exist beyond the
        current page. Absent on the final page — clients must detect
        "no more data" by checking for the absence of this object,
        not by parsing `next`.
      properties:
        next:
          type: string
          nullable: true
          description: |
            Opaque token to pass as the `cursor` query parameter on
            the next request. Base64url-encoded; contents are an
            implementation detail and change without notice. Clients
            MUST pass this value verbatim — do not decode or
            manipulate.

    EnvelopeMeta:
      type: object
      description: Every 2xx response carries these.
      properties:
        as_of: { type: string, format: date-time }
        sources:
          type: array
          items: { type: string }
        flags: { $ref: "#/components/schemas/Flags" }
      required: [as_of, flags]

    # One protocol-directory row (/protocols and the base of
    # /protocols/{name}'s detail view).
    ProtocolRow:
      type: object
      required:
        [name, category, description, genesis_ledger, factories,
         contract_count, events_24h]
      properties:
        name: { type: string, example: blend }
        category:
          type: string
          enum: [dex, amm, lending, yield, bridge, oracle, token]
        description: { type: string }
        genesis_ledger:
          type: integer
          format: int64
          description: First ledger this protocol could have data at.
        factories:
          type: array
          description: Verified factory / trust-root contract C-strkeys (ADR-0035); empty for factory-less sources.
          items: { type: string }
        contract_count:
          type: integer
          description: Registered contract instances (protocol_contracts; soroswap_pairs for soroswap).
        events_24h:
          type: integer
          format: int64
          description: Trailing-24h decoded events across the protocol's served tables.
        completeness:
          type: object
          description: Latest ADR-0033 verdict summary; absent when no snapshot exists. Full verdict on /coverage.
          required: [complete, watermark_ledger]
          properties:
            complete: { type: boolean }
            watermark_ledger: { type: integer, format: int64 }

    # Health
    HealthResponse:
      type: object
      properties:
        status: { type: string, enum: [ok, degraded] }
        # The handler serves the same healthResponse struct on both
        # /healthz and /readyz — uptime + status_root are always
        # populated on /healthz too (checks stays absent there).
        uptime: { type: string, description: "Human-readable process uptime, truncated to the second (e.g. `3h12m4s`)." }
        status_root: { type: string, description: "Pointer at `/v1/status` — the SLA-truth rollup (F-1210)." }
      required: [status]

    # /readyz body shape (enveloped). `status` carries three values:
    # `ok` (all checks pass), `degraded` (a non-critical dependency
    # failed — still 200), `unready` (a critical dependency failed —
    # 503). See server.go handleReadyz.
    ReadyResponse:
      type: object
      properties:
        status:
          type: string
          enum: [ok, degraded, unready]
          description: |
            `ok` when every dependency check passed; `degraded`
            (HTTP 200) when a NON-critical dependency failed and the
            API still serves via fallback; `unready` (HTTP 503) when
            a CRITICAL dependency failed.
        uptime:
          type: string
          description: Human-readable process uptime, truncated to the second (e.g. `3h12m4s`).
        checks:
          type: array
          description: Per-dependency ping results. Present on `/readyz`; absent on `/healthz`.
          items:
            type: object
            properties:
              name:  { type: string, description: "Dependency name (e.g. `postgres`, `redis`)." }
              ok:    { type: boolean, description: "True when the dependency answered its ping within the deadline." }
              error: { type: string, description: "Failure reason; present only when `ok` is false." }
            required: [name, ok]
        status_root:
          type: string
          description: |
            Static link (`/v1/status`) to the rich health rollup, so a
            probe consumer following only `/readyz`/`/healthz` can find
            the SLA-truth endpoint without out-of-band knowledge.
      required: [status, uptime]

    ReadyEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/ReadyResponse" }
          required: [data]

    VersionResponse:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: object
              properties:
                version:    { type: string, description: "Human-readable git-describe (or `dev`)." }
                build_date: { type: string, description: "ISO-8601 UTC build timestamp (or `unknown`)." }
                commit:     { type: string, description: "Full VCS revision SHA from runtime/debug.BuildInfo (or `unknown`)." }
                dirty:      { type: string, description: "`true` if the build tree had uncommitted changes; `false` otherwise. Production builds should always be `false`." }
                go_version: { type: string, description: "Runtime Go version (e.g. `go1.22.3`)." }
              required: [version, build_date, commit, dirty, go_version]
          required: [data]

    StatusEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/StatusResponse" }
          required: [data]

    StatusResponse:
      type: object
      properties:
        overall:
          type: string
          enum: [ok, degraded, down]
          description: |
            "ok" when every signal is healthy; "degraded" when at
            least one heartbeat is stale or a page-severity alert
            is firing; "down" when no live signal is available.
        region:
          type: object
          properties:
            name:       { type: string, description: "Region identifier (e.g. r1, r2, r3)." }
            deployment: { type: string, description: "Deployment label (e.g. production, staging)." }
          required: [name, deployment]
        services:
          type: array
          items: { $ref: "#/components/schemas/StatusService" }
        latency:
          type: object
          description: API histogram-derived percentiles over the last 5 minutes. Zero when no Prometheus backend is wired.
          properties:
            p50_ms:      { type: number }
            p95_ms:      { type: number }
            p99_ms:      { type: number }
            window_secs: { type: integer }
        freshness:
          type: object
          properties:
            last_aggregator_tick: { type: string, format: date-time }
            active_sources:
              type: integer
              description: |
                Sources that have emitted an event in the last 10
                minutes (Prometheus
                `count(rate(stellarindex_source_events_total[10m]) > 0)`).
            total_sources:
              type: integer
              description: |
                Sources the operator has ENABLED in this region
                (Prometheus `count(stellarindex_source_enabled == 1)`).
                Different from `/v1/network/stats.total_sources`,
                which counts every source REGISTERED in the binary
                regardless of enable state — typically a strict
                superset. Today on r1: enabled=17, registered=21,
                active=15.
        incidents:
          type: object
          properties:
            active_count:        { type: integer }
            page_count:          { type: integer }
            ticket_count:        { type: integer }
            informational_count: { type: integer }
            active:
              type: array
              description: |
                Currently-firing alerts (deduplicated by alertname,
                page-severity first, capped at 16 entries). Empty when
                no alerts are firing OR no Prometheus backend is wired.
              items: { $ref: "#/components/schemas/ActiveIncident" }
      required: [overall, region, services]

    ActiveIncident:
      type: object
      properties:
        name:     { type: string, description: "Alertmanager `alertname` label." }
        severity:
          type: string
          enum: [page, ticket, informational]
          description: Severity bucket per the Stellar Index alerting taxonomy.
        runbook_url:
          type: string
          format: uri
          description: |
            Public GitHub URL for the alert's runbook (when the alert
            rule has the `runbook_url` label set). Empty when no
            runbook is registered.
      required: [name, severity]

    StatusService:
      type: object
      properties:
        name:      { type: string, description: "Service identifier (api / indexer / aggregator)." }
        status:
          type: string
          enum: [ok, down, unknown]
          description: |
            "ok" when the last scrape was within 60 s. "down" when
            the heartbeat is stale. "unknown" when no Prometheus
            backend is wired or no scrape has succeeded yet.
        last_seen: { type: string, format: date-time, description: "Optional. Timestamp of the most recent successful scrape." }
      required: [name, status]

    # Asset catalogue
    Asset:
      type: object
      properties:
        asset_id:     { type: string }
        type:         { type: string, enum: [native, classic, soroban, fiat, global, external], description: "`global` = a catalogue-identity listing row (slug id); `external` = a reference-only (non-Stellar-issued) asset. Both appear on listing surfaces (assets.go projectCatalogueRow)." }
        code:         { type: string }
        issuer:       { type: string, nullable: true }
        contract_id:
          type: string
          nullable: true
          description: >-
            C-strkey contract address. For Soroban tokens this is the
            token contract itself; for classic + native assets it is the
            deterministically-derived Stellar Asset Contract (SAC)
            address — present even before the SAC is deployed, since
            deployment is permissionless and address-stable. Looking an
            asset up BY a SAC address resolves to the wrapped classic
            asset (with its price), so wallets can use contract addresses
            as the primary key for any holding.
        home_domain:  { type: string, nullable: true }
        decimals:     { type: integer }
        sep1_status:
          type: string
          enum: [not_applicable, not_fetched, verified, no_match, unreachable]
          description: |
            State of the SEP-1 overlay for this asset.
            - not_applicable: no home-domain (native, fiat, SAC-only).
            - not_fetched:    has a home-domain but resolver wasn't wired.
            - verified:       SEP-1 fetched + matching [[CURRENCIES]] entry found.
            - no_match:       SEP-1 fetched but no matching issuer+code entry.
            - unreachable:    fetch / parse failed (see server logs).
        # Overlay fields, populated only when sep1_status == "verified"
        # (some also present for "no_match" — org_name).
        name:              { type: string, nullable: true }
        description:       { type: string, nullable: true }
        image:             { type: string, nullable: true }
        org_name:          { type: string, nullable: true }
        anchor_asset:      { type: string, nullable: true }
        anchor_asset_type: { type: string, nullable: true }
        # SEP-1 issuance declarations (issuer's stated commitments,
        # distinct from the live-ledger F2 fields below).
        conditions:        { type: string, nullable: true, description: "Issuer's declared terms / conditions text." }
        fixed_number:      { type: string, nullable: true, description: "SEP-1-declared fixed total supply (decimal string in the asset's smallest integer unit). Distinct from `total_supply` below — that's the live-ledger sum." }
        max_number:        { type: string, nullable: true, description: "SEP-1-declared maximum supply ceiling (decimal string). Distinct from `max_supply` below which is operator/policy-derived." }
        is_unlimited:      { type: boolean, nullable: true, description: "Issuer asserts unbounded issuance. Null when the issuer didn't address supply at all; false when they declared a bounded supply." }
        # F2 fields — Market Cap / FDV / Circulating / Total / Max
        # Supply (ADR-0011).
        #
        # Supply values are decimal strings in the asset's smallest
        # integer unit (stroops for XLM/classic; contract-defined for
        # SEP-41). Consumers divide by 10^decimals for display.
        # market_cap_usd / fdv_usd are decimal strings in dollars
        # to two fractional digits.
        #
        # Current repo snapshot note: the API reads these fields from
        # asset_supply_history when snapshots exist, but the repo does
        # not ship an in-tree writer for that table yet.
        #
        # All F2 fields are nullable: any field whose data isn't
        # available (no supply snapshot yet, no USD price for the
        # pair, max_supply unknown for an uncapped issuer) emits null
        # rather than fabricating per ADR-0011 "we don't fabricate".
        circulating_supply: { type: string, nullable: true, description: "Raw integer in asset's smallest unit (per ADR-0011 supply derivation). Null when no snapshot exists." }
        total_supply:       { type: string, nullable: true, description: "Raw integer in asset's smallest unit. Null when no snapshot exists." }
        max_supply:         { type: string, nullable: true, description: "Raw integer in asset's smallest unit. Null for uncapped issuers without operator override or SEP-1 declaration." }
        price_usd:          { type: string, nullable: true, description: "Current per-asset USD price as a fixed-precision decimal string — same value `/v1/price?asset=…&quote=fiat:USD` returns. Inlined so wallet UIs don't need a second round-trip. Null when no USD price can be derived." }
        change_24h_pct:     { type: string, nullable: true, description: "Trailing-24h price change as a signed decimal percentage with two fractional digits (e.g. \"+1.27\", \"-0.05\", \"0.00\"). Null when the asset has no current USD price or no comparison bucket ~24h ago." }
        market_cap_usd:     { type: string, nullable: true, description: "circulating_supply × USD price / 10^decimals, two fractional digits. Null when supply or USD price is unavailable." }
        fdv_usd:            { type: string, nullable: true, description: "max_supply × USD price / 10^decimals, two fractional digits. Null when max_supply is null or USD price unavailable." }
        supply_basis:
          type: string
          enum: [xlm_sdf_reserve_exclusion, issuer_exclusion, admin_exclusion, override]
          nullable: true
          description: |
            Which ADR-0011 policy produced the supply numbers.
            Surfaced so consumers can decide how much to trust the
            absolute value (e.g. `override` indicates an operator
            curated the locked-set or SEP-1 declared a max_supply;
            `issuer_exclusion`/`admin_exclusion` are algorithm
            defaults). Null when no supply snapshot is available.
        volume_24h_usd:
          type: string
          nullable: true
          description: |
            Trailing-24h USD-denominated trade volume across every
            pair this asset participates in (as base OR quote).
            Decimal string. "0" is a valid value (asset tracked, no
            trades in the window); null means the volume reader
            isn't wired or the lookup failed. Per Freighter V2
            scope ("24h Trading Volume aggregate across indexed
            markets").

            **Scope caveat (launch-readiness L2.2):** off-chain
            CEX/FX trades populate `usd_volume` automatically when
            the quote is `fiat:USD` or a USD-pegged stablecoin
            (USDC / USDT / DAI / PYUSD / USDP).

            On-chain DEX trades (Stellar SDEX, Soroswap, Aquarius,
            Phoenix, Comet) populate `usd_volume` when their quote
            asset is in the operator's
            `[trades].usd_pegged_classic_assets` allow-list, or its
            SAC contract is wired transitively via
            `[supply.sac_wrappers]` (L2.2 phase 1). Deployments
            that haven't configured the allow-list see on-chain
            trades contribute 0 — comparisons with external
            aggregators (CoinGecko, CMC) will read systematically
            lower until the operator opts in.

            On-chain trades quoted in non-USD assets (XLM/AQUA,
            XLM/BTC, etc.) still contribute 0; the FX-anchor
            multiplication for non-USD on-chain quotes is L2.2
            phase 2, post-launch. The schema is forward-compatible:
            phase 2 lands additively without a wire-shape change.
        # ─── Coin-overlay listing fields (assets.go applyCoinExtensionFields
        # + projectCatalogueRow) — populated on listing/detail surfaces when
        # the CoinsReader is wired; null/absent otherwise. Documented
        # 2026-07-02 (board #33): these were served but unspecced.
        slug:               { type: string, description: "Catalogue slug when the asset has a verified-currency identity (e.g. \"usdc\")." }
        class:              { type: string, enum: [crypto, stablecoin, fiat], description: "Verified-currency taxonomy when catalogued." }
        change_1h_pct:      { type: string, nullable: true, description: "Trailing-1h price change, signed decimal percentage." }
        change_7d_pct:      { type: string, nullable: true, description: "Trailing-7d price change, signed decimal percentage." }
        first_seen_ledger:  { type: integer, format: int64, nullable: true, description: "First ledger this asset was observed trading." }
        last_seen_ledger:   { type: integer, format: int64, nullable: true, description: "Most recent ledger this asset was observed trading." }
        observation_count:  { type: integer, format: int64, nullable: true, description: "Total observed trade rows for this asset." }
        markets_count:      { type: integer, nullable: true, description: "Distinct (base, quote) pairs over the trailing 24h." }
        trade_count_24h:    { type: integer, nullable: true, description: "Trades the asset participated in over the trailing 24h." }
        price_history_24h:
          type: array
          nullable: true
          description: "24 hourly USD-price samples (oldest first) for sparkline rendering."
          items:
            type: object
            properties:
              t: { type: string, format: date-time }
              p: { type: string, nullable: true }
            required: [t]
        price_history_7d:
          type: array
          nullable: true
          description: "7 daily USD-price samples (oldest first)."
          items:
            type: object
            properties:
              t: { type: string, format: date-time }
              p: { type: string, nullable: true }
            required: [t]
        ath:
          type: object
          nullable: true
          description: "All-time-high USD price + when it was set. Null when no USD-quoted history."
          properties:
            usd: { type: string }
            at:  { type: string, format: date-time }
          required: [usd, at]
        top_markets:
          type: array
          nullable: true
          description: "Top markets by 24h USD volume; each row is the counterparty of a pair the asset participated in."
          items:
            type: object
            properties:
              counterparty:    { type: string, description: "The OTHER side of the pair." }
              side:            { type: string, enum: [base, quote], description: "Which side this asset took." }
              volume_24h_usd:  { type: string, nullable: true }
              trade_count_24h: { type: integer }
            required: [counterparty, side, trade_count_24h]
        issuer_scam_reason: { type: string, description: "Non-empty when this asset's issuer appears in the curated scam directory." }
        unverified_warning:
          allOf:
            - { $ref: "#/components/schemas/UnverifiedWarning" }
          nullable: true
          description: |
            Attached when the requested asset's code matches a
            verified currency's Stellar ticker (USDC, EURC, AQUA, …)
            but the issuer doesn't match the verified entry. Lights
            `flags.unverified_ticker_collision` on the envelope.

            Null for the verified asset itself, for non-classic
            assets (native / Soroban / fiat), and for any code that
            no verified currency claims on Stellar. See R-018 /
            docs/architecture/multi-network-assets-migration.md
            Phase 1.1.
      required: [asset_id, type, code, decimals, sep1_status]

    UnverifiedWarning:
      type: object
      description: |
        Pointer at the verified Stellar-canonical asset when an
        unverified asset shares the same ticker code. Every field
        is human-render-ready — the `note` sentence is verbatim-safe
        for a warning banner.
      properties:
        verified_slug:     { type: string,  description: "Canonical slug (e.g. \"usdc\") the consumer can redirect to." }
        verified_asset_id: { type: string,  description: "Verified canonical asset_id (e.g. USDC-GA5Z…)." }
        verified_name:     { type: string,  description: "Human-readable currency name (e.g. \"USD Coin\")." }
        verified_issuer:   { type: string,  nullable: true, description: "Short attribution (e.g. \"Circle (centre.io)\"). Empty when the catalogue entry didn't include a verified_issuer_label." }
        note:              { type: string,  description: "One-sentence warning rendered verbatim by clients." }
      required: [verified_slug, verified_asset_id, verified_name, note]

    GlobalAssetView:
      type: object
      description: |
        Verified-currency identity + headline price surface served by
        /v1/assets/{slug}. Distinct from `Asset`, the per-Stellar-asset
        detail view. Consumers reach this surface by passing a catalogue
        slug (e.g. `usdc`); the `Asset` surface is reached by passing a
        canonical asset_id (e.g. `USDC-GA5Z…`).
      properties:
        class:            { type: string, enum: [crypto, stablecoin, fiat], description: "Asset taxonomy for the catalogue identity. Always present (assets_global.go)." }
        ticker:           { type: string,  description: "Display ticker (e.g. \"USDC\")." }
        slug:             { type: string,  description: "URL slug (lowercase, e.g. \"usdc\")." }
        name:             { type: string,  description: "Human-readable currency name." }
        description:      { type: string,  description: "One-sentence summary." }
        verified_issuer:  { type: string,  description: "Short attribution string (e.g. \"Circle (centre.io)\"). Empty when the catalogue entry didn't include a verified_issuer_label." }
        coingecko_id:     { type: string,  description: "CoinGecko slug for this currency (when known)." }
        coinmarketcap_id: { type: string,  description: "CoinMarketCap integer ID for this currency (when known)." }
        price_usd:        { type: string,  nullable: true, description: "USD price from the three-tier fallback chain. Null when no tier produced a price — consumer drills into the canonical /v1/assets/{asset_id} surface for the per-Stellar-asset price instead." }
        price_authority:
          type: string
          enum: [vwap_native, aggregator_avg, triangulated]
          description: |
            Which tier of the fallback chain produced `price_usd`.
            - `vwap_native`: our own VWAP across exchange-class trades.
            - `aggregator_avg`: average across CG / CMC / CryptoCompare.
            - `triangulated`: derived via bridge currency (X_USD ≈
              X_BTC × BTC_USD or similar).
        price_sources:    { type: array, items: { type: string }, description: "Contributor venue / aggregator names — for transparency." }
        price_as_of:      { type: string, format: date-time, nullable: true, description: "Observation timestamp of the served price." }
        circulating_supply: { type: string, nullable: true, description: "Natural-unit amount in circulation. For fiat: M2 (broad money). Empty for crypto/stablecoin (the per-Stellar-asset F2 fields on /v1/assets/{asset_id} are the canonical source)." }
        supply_decimals:  { type: integer, description: "Exponent mapping circulating_supply to display value. 0 for fiat." }
        market_cap_usd:   { type: string, nullable: true, description: "Decimal string (2 fractional digits). Computed for fiat rows: M2 × current FX rate. Null for crypto/stablecoin (their market cap lives on the per-Stellar-asset F2 fields)." }
      required: [ticker, slug, name]

    GlobalAssetEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/GlobalAssetView" }
          required: [data]

    VerifiedCurrencyListItem:
      type: object
      description: |
        One entry in the response to GET /v1/assets/verified — the
        verified-currency catalogue directory. Distinct from
        GlobalAssetView: no price block (listing payload stays small;
        consumers fetch /v1/assets/{slug} per row for pricing).
      properties:
        ticker:           { type: string, description: "Display ticker (e.g. \"USDC\")." }
        slug:             { type: string, description: "URL slug (lowercase, e.g. \"usdc\")." }
        name:             { type: string, description: "Human-readable currency name." }
        verified_issuer:  { type: string, description: "Short attribution string (e.g. \"Circle (centre.io)\"). Empty when the catalogue entry didn't include a verified_issuer_label." }
        image:
          type: string
          nullable: true
          description: >-
            Asset logo URL from the issuer's SEP-1 TOML (https-only,
            sanitized). Wallets bulk-load logos from this listing.
        coingecko_id:     { type: string, description: "CoinGecko slug for this currency (when known)." }
        coinmarketcap_id: { type: string, description: "CoinMarketCap integer ID for this currency (when known)." }
        market_cap_usd:   { type: string, description: "Decimal string (2 fractional digits). Computed for fiat rows only: M2 × current FX rate. Crypto/stablecoin rows leave this empty (their per-Stellar-asset F2 fields on /v1/assets/{asset_id} are the canonical source)." }
        class:            { type: string, enum: [crypto, stablecoin, fiat], description: "Asset taxonomy — drives listing-side rendering + filtering." }
        circulating_supply: { type: string, description: "Natural-unit amount in circulation. For fiat: M2 (broad money). Empty for crypto/stablecoin." }
        supply_decimals:  { type: integer, description: "Exponent mapping circulating_supply to display value. 0 for fiat." }
      required: [ticker, slug, name, class]

    VerifiedCurrencyListEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/VerifiedCurrencyListItem" }
          required: [data]

    AssetEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Asset" }
          required: [data]

    AssetListEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Asset" }
            pagination: { $ref: "#/components/schemas/Pagination" }
          # pagination is OMITTED on the final page (no more rows).
          # Matches the handler behaviour: env.Pagination is set only
          # when the storage layer returned a non-empty next-cursor.
          required: [data]

    AssetMetadata:
      type: object
      description: |
        SEP-1 overlay re-projection of /v1/assets/{id}'s metadata
        slice. Same shape the asset-detail surface returns under
        the `name`/`description`/etc. keys, here without the
        ledger-derived F2 fields. Populated when the issuer
        publishes a stellar.toml whose [[CURRENCIES]] entry
        matches the asset's `(code, issuer)` — `sep1_status`
        then reads "verified". `not_applicable` (native, fiat,
        SAC-only), `not_fetched` (operator hasn't configured the
        home-domain map for this issuer), `unreachable` (fetch
        or parse failed), and `no_match` (TOML loaded but no
        matching currency block) leave the overlay fields null.
      properties:
        asset_id:        { type: string }
        home_domain:     { type: string, nullable: true }
        sep1_status:     { type: string, enum: [not_applicable, not_fetched, verified, no_match, unreachable] }
        # SEP-1 overlay fields (populated when sep1_status=verified).
        name:            { type: string, nullable: true }
        description:     { type: string, nullable: true }
        image:           { type: string, nullable: true, description: "Issuer-supplied logo URL. http(s) only — non-http schemes are stripped at overlay time." }
        org_name:        { type: string, nullable: true }
        anchor_asset:    { type: string, nullable: true }
        anchor_asset_type: { type: string, nullable: true, description: "fiat / crypto / stock / etc., per SEP-1 §Currencies." }
        # SEP-1 issuance declarations — the issuer's own commitments,
        # distinct from F2 supply numbers on /v1/assets/{id} which
        # observe live ledger state.
        conditions:      { type: string, nullable: true, description: "Issuer's declared terms / conditions text." }
        fixed_number:    { type: string, nullable: true, description: "SEP-1-declared fixed total supply (decimal string in the asset's smallest integer unit). Distinct from /v1/assets/{id}'s `total_supply` which is the live-ledger sum." }
        max_number:      { type: string, nullable: true, description: "SEP-1-declared maximum supply ceiling (decimal string). Distinct from /v1/assets/{id}'s `max_supply` which is operator/policy-derived." }
        is_unlimited:    { type: boolean, nullable: true, description: "Issuer asserts unbounded issuance. Null when the issuer didn't address supply at all (no fixed_number / max_number / is_unlimited declaration); false when they did and committed to a bounded supply." }
      required: [asset_id, sep1_status]

    AssetMetadataEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AssetMetadata" }
          required: [data]

    AssetSupply:
      type: object
      description: |
        Live per-token supply from the decode-at-ingest supply_flows lake
        (ADR-0034). Amounts are decimal strings in the asset's smallest unit.
      properties:
        asset_id:       { type: string, description: "The requested asset_id, echoed back." }
        contract_id:    { type: string, description: "The contract (Soroban token or classic SAC) supply is keyed by. Omitted for native XLM." }
        total_supply:   { type: string, description: "Decimal string. mint − burn − clawback (or the ledger total_coins for native). Never a JSON number (ADR-0003)." }
        mint_total:     { type: string, description: "Decimal string: Σ mint. Omitted for native." }
        burn_total:     { type: string, description: "Decimal string: Σ burn. Omitted for native." }
        clawback_total: { type: string, description: "Decimal string: Σ clawback. Omitted for native." }
        flow_count:     { type: integer, format: int64, description: "Number of supply-affecting events summed." }
        source:
          type: string
          enum: [mint_burn_flows, ledger_total_coins]
          description: "How total_supply was derived: mint/burn/clawback flows, or the ledger header total_coins (native XLM)."
      required: [asset_id, total_supply, flow_count, source]

    AssetSupplyEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AssetSupply" }
          required: [data]

    # Price
    #
    # Fields marked "aggregator" are populated once the aggregator
    # binary lands (see cmd/stellarindex-aggregator/main.go). Until
    # then, handlers may omit them — clients must treat absence as
    # "unknown", not an error. Nullable on the wire; required-by-name
    # in the OpenAPI sense means "the key will appear", not "it will
    # have a value".
    Price:
      # The /v1/price family payload — EXACTLY the fields the handler's
      # PriceSnapshot serves (internal/api/v1/price.go). Asset-level
      # enrichment (market cap, supply, change_%, sparklines, ATH,
      # top_markets, scam flags) lives on the Asset schema served by
      # /v1/assets — it was previously (wrongly) documented here too;
      # the price surface never carried those fields. Contract-tested
      # against the SDK in pkg/client/spec_contract_test.go.
      type: object
      properties:
        asset_id:          { type: string }
        quote:             { type: string }
        price:             { type: string, description: "Decimal string. Never JSON number." }
        price_type:
          type: string
          enum: [vwap, twap, last_trade, peg]
          description: |
            How the price was derived. `peg` is emitted on the
            stablecoin self-peg path (`/v1/price?asset=<USD-pegged
            classic>&quote=fiat:USD` returns `1.0` with
            `flags.triangulated=true`) — see price.go
            tryStablecoinFiatProxy.
        # observed_at = close-time of the underlying last_trade (for
        # last_trade price_type) or end of the aggregation window
        # (for vwap/twap). RFC 3339, UTC.
        observed_at:       { type: string, format: date-time }
        window_seconds:    { type: integer, nullable: true, description: "Window size for vwap/twap; omitted for last_trade." }
        change_24h_pct:
          type: string
          nullable: true
          description: >-
            Trailing-24h percentage change vs the asset's USD price ~24h
            ago (signed, two fractional digits — "+1.27"). Present on
            /v1/price/batch rows when the quote is fiat:USD and a closed
            comparison bucket exists; omitted otherwise. Pairs current
            price with 24h change in ONE bulk call for wallet portfolio
            screens.
        # Confidence score per ADR-0019. Populated only on /v1/price
        # (the closed-bucket surface) when the aggregator's
        # confidence-compute path has cached a fresh score for the
        # pair. Omitted when no cached score is available — clients
        # that gate on confidence MUST treat the absence as "unknown",
        # not "low".
        confidence:        { type: number, format: float, nullable: true, minimum: 0, maximum: 1, description: "Multi-factor confidence score in [0, 1] (ADR-0019)." }
        confidence_factors:
          nullable: true
          type: object
          description: "Per-factor decomposition of confidence (ADR-0019)."
          properties:
            z_score:          { type: number, format: float }
            source_count:     { type: number, format: float }
            diversity:        { type: number, format: float }
            liquidity:        { type: number, format: float }
            cross_oracle:     { type: number, format: float }
            baseline_quality: { type: number, format: float }
      required: [asset_id, quote, price, price_type, observed_at]

    PriceEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Price" }
          required: [data]

    PriceBatchEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Price" }
          required: [data]

    # History
    #
    # Today /history returns raw per-trade rows (TradeRow). The
    # aggregator-backed bucketed history (OHLC/VWAP time series at
    # granularity 1m/15m/...) will ship on a different endpoint or
    # a `bucketed=true` flag — this schema reflects what the handler
    # actually serves.
    #
    # HistoryPoint + HistoryEnvelope are the wire shape for
    # /history/since-inception (a CAGG-served per-day VWAP series
    # back to ledger 1 — see PR #195). They are NOT what /history
    # returns today; /history uses the bucketed CandleEnvelope.
    HistoryPoint:
      type: object
      properties:
        t:     { type: string, format: date-time }
        p:     { type: string }
        v_usd: { type: string, nullable: true }
      required: [t, p]

    HistoryEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: object
              properties:
                asset_id:    { type: string }
                quote:       { type: string }
                price_type:  { type: string, enum: [vwap, twap] }
                granularity: { type: string }
                points:
                  type: array
                  items: { $ref: "#/components/schemas/HistoryPoint" }
              required: [asset_id, quote, price_type, granularity, points]
          required: [data]

    ChartEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: object
              properties:
                asset_id:    { type: string }
                quote:       { type: string }
                timeframe:   { type: string, enum: [1h, 24h, 1w, 1mo, 1y, all] }
                granularity: { type: string, enum: [1m, 15m, 1h, 4h, 1d, 1w, 1mo] }
                price_type:
                  type: string
                  enum: [vwap, market_cap]
                  description: |
                    `vwap` for the default price chart; `market_cap`
                    on the market-cap chart variant (chart.go
                    handleChartMarketCap). `twap` is never emitted on
                    this surface.
                points:
                  type: array
                  items: { $ref: "#/components/schemas/HistoryPoint" }
                truncated:
                  type: boolean
                  description: |
                    True when the requested timeframe extends before
                    the earliest available data on this deployment
                    (e.g. asking `?timeframe=1y` when retention is
                    only 7 days). Always false for `timeframe=all`.
                data_starts_at:
                  type: string
                  format: date-time
                  description: |
                    Earliest bucket in the result set. Only present
                    when `truncated=true`. Render as "history begins
                    at <ts>" so users know to widen retention or
                    pick a shorter timeframe.
                requested_from:
                  type: string
                  format: date-time
                  description: |
                    Window start the consumer asked for (derived
                    from `timeframe`). Only present when
                    `truncated=true`.
              required: [asset_id, quote, timeframe, granularity, price_type, points, truncated]
          required: [data]

    TradeRow:
      type: object
      properties:
        source:       { type: string }
        ledger:       { type: integer }
        tx_hash:      { type: string, description: "64 lowercase hex chars." }
        op_index:     { type: integer }
        ts:           { type: string, format: date-time }
        base_asset:   { type: string }
        quote_asset:  { type: string }
        base_amount:  { type: string, description: "Integer stroops, decimal string." }
        quote_amount: { type: string, description: "Integer stroops, decimal string." }
        price:        { type: string, description: "quote/base, 10-digit decimal." }
      required: [source, ledger, tx_hash, op_index, ts, base_asset, quote_asset, base_amount, quote_amount, price]

    TradeHistoryEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/TradeRow" }
            pagination: { $ref: "#/components/schemas/Pagination" }
          required: [data]

    # OHLC
    OHLCBar:
      type: object
      properties:
        from:
          type: string
          format: date-time
          description: |
            Inclusive lower bound of the bar window. Like VWAPResult.from,
            see ADR-0015 for the closed-bucket clamp semantics that
            preserve cross-region rate equality.
        to:
          type: string
          format: date-time
          description: |
            Exclusive upper bound of the bar window. Clamped to a 30 s
            boundary when the request omitted `to`.
        open:         { type: string, description: "Decimal string, 10 digits." }
        high:         { type: string }
        low:          { type: string }
        close:        { type: string }
        base_volume:  { type: string, description: "Σ base_amount integer stroops." }
        quote_volume: { type: string, description: "Σ quote_amount integer stroops." }
        trade_count:  { type: integer }
        truncated:    { type: boolean, description: "True when window exceeded the per-request trade cap; bar reflects only the first N." }
      required: [from, to, open, high, low, close, base_volume, quote_volume, trade_count, truncated]

    OHLCEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/OHLCBar" }
          required: [data]

    # OHLC series (CG/CMC parity, F-0071).
    OHLCSeriesBar:
      type: object
      description: |
        One bar in a multi-bar /v1/ohlc?interval= response. Compact
        wire field names (`t/o/h/l/c/v_base/v_quote/n`) match the
        convention used by CoinGecko / CoinMarketCap so this endpoint
        can be a drop-in replacement for those vendors' OHLC feeds.

        `t` is the bucket-start timestamp aligned to UTC interval
        boundaries (1h → top of hour, 1d → 00:00 UTC, 1w → Monday
        00:00 UTC). Bucket end = `t + interval`.
      properties:
        t:
          type: string
          format: date-time
          description: Bucket-start timestamp (UTC, interval-aligned).
        o: { type: string, description: "Open price (decimal string)." }
        h: { type: string, description: "High price (decimal string)." }
        l: { type: string, description: "Low price (decimal string)." }
        c: { type: string, description: "Close price (decimal string)." }
        v_base:  { type: string, description: "Σ base_amount stroops over the bucket (decimal string)." }
        v_quote: { type: string, description: "Σ quote_amount stroops over the bucket (decimal string)." }
        n: { type: integer, format: int64, description: "Trade count in the bucket." }
        truncated:
          type: boolean
          description: "Reserved for future row-cap signalling; absent today."
      required: [t, o, h, l, c, v_base, v_quote, n]

    OHLCSeriesResponse:
      type: object
      description: Multi-bar OHLC series response (F-0071).
      properties:
        base:     { type: string, description: "Canonical base asset id." }
        quote:    { type: string, description: "Canonical quote asset id." }
        interval: { type: string, enum: [1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1mo] }
        from:
          type: string
          format: date-time
          description: |
            Inclusive lower bound of the series window. When the
            client omitted `from`, this is `to - limit*interval`.
        to:
          type: string
          format: date-time
          description: |
            Exclusive upper bound. When the client omitted `to`, this
            is now() snapped DOWN to the interval's UTC boundary —
            ADR-0015 cross-region rate-equality guarantee.
        intervals:
          type: array
          items: { $ref: "#/components/schemas/OHLCSeriesBar" }
      required: [base, quote, interval, from, to, intervals]

    OHLCSeriesEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/OHLCSeriesResponse" }
          required: [data]

    # VWAP / TWAP
    #
    # IMPORTANT — closed-bucket serving (ADR-0015)
    #
    # The `from` and `to` fields collectively act as the response's
    # `as_of` range — they tell the client EXACTLY which window the
    # rate covers. When the client did not supply a `to` query
    # parameter, the server clamps the implicit "now" DOWN to the
    # nearest 30-second boundary. This is what makes "all 3 regions
    # serve the same rate" a real property: two requests landing in
    # the same 30 s window resolve to identical [from, to) ranges
    # and (once underlying trades have replicated to all regions)
    # byte-equivalent JSON.
    #
    # When the client DID supply `to` explicitly, that value is
    # preserved verbatim — the clamp only applies to the default-now
    # path.
    #
    # Implication: rates served by these endpoints are 0–30 s old
    # (default window). This is within the Freighter ≤30 s freshness
    # SLA. Clients needing tick-by-tick live data should subscribe
    # to /v1/price/stream (closed-bucket SSE) rather than polling
    # these.
    VWAPResult:
      type: object
      properties:
        from:
          type: string
          format: date-time
          description: |
            Inclusive lower bound of the window the rate covers.
            Used as part of the as_of identity for cross-region rate
            equality (ADR-0015).
        to:
          type: string
          format: date-time
          description: |
            Exclusive upper bound of the window. When the request
            omitted `to`, this is clamped down to a 30-second
            boundary so two parallel requests in the same window
            return identical responses across regions. When the
            request supplied `to` explicitly, this echoes the
            request value.
        price:             { type: string, description: "VWAP decimal, 10 digits." }
        base_volume:       { type: string }
        quote_volume:      { type: string }
        trade_count:       { type: integer }
        outliers_filtered: { type: integer }
        truncated:         { type: boolean }
      required: [from, to, price, base_volume, quote_volume, trade_count, outliers_filtered, truncated]

    VWAPEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/VWAPResult" }
          required: [data]

    TWAPResult:
      type: object
      properties:
        from:
          type: string
          format: date-time
          description: |
            Inclusive lower bound of the window. See VWAPResult.from
            and ADR-0015 for the closed-bucket clamp semantics.
        to:
          type: string
          format: date-time
          description: |
            Exclusive upper bound of the window. Clamped to a 30 s
            boundary when the request omitted `to`. See VWAPResult.to.
        price:       { type: string, description: "TWAP decimal, 10 digits." }
        trade_count: { type: integer }
        truncated:   { type: boolean }
      required: [from, to, price, trade_count, truncated]

    TWAPEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/TWAPResult" }
          required: [data]

    # Oracle latest
    OracleReading:
      type: object
      properties:
        source:      { type: string, description: "e.g. reflector-dex, reflector-cex, reflector-fx." }
        contract_id: { type: string, description: "C-strkey, omitted for off-chain sources." }
        asset:       { type: string }
        quote:       { type: string }
        ts:          { type: string, format: date-time }
        price:       { type: string, description: "Decimal string at declared decimals scale." }
        price_raw:   { type: string, description: "Raw integer value preserved for cross-check (ADR-0003)." }
        decimals:    { type: integer, minimum: 0, maximum: 38 }
        confidence:  { type: number, minimum: 0, maximum: 1, description: "0 means unreported, not zero-confidence." }
        observer:    { type: string, description: "G-strkey of the publishing account; empty when unknown." }
      required: [source, asset, quote, ts, price, price_raw, decimals]

    OracleLatestEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/OracleReading" }
          required: [data]

    # Markets / Pairs
    #
    # MarketRow is one (base, quote) pair present in the trades
    # store. `last_price` is still pending (lands with the aggregator);
    # `volume_24h_usd` is summed from prices_1m's per-bucket
    # volume_usd (nullable when the pair has no USD-equivalent
    # trades).
    #
    # `last_trade_at` vs `bucket_close_at`: prior to 2026-05-27 the
    # `last_trade_at` field was sourced from MAX(prices_1d.bucket)
    # (daily bucket-start), so most rows returned exactly-midnight
    # UTC values and clients computing staleness against now() saw
    # spuriously-large values (F-0065). Post-fix, `last_trade_at`
    # carries the minute-precise prices_1m bucket-start for in-24h-
    # active pairs (falling back to the daily bucket-start when no
    # minute-precise signal exists), and `bucket_close_at` exposes
    # the daily bucket-start explicitly for callers that want it.
    MarketRow:
      type: object
      properties:
        base:            { type: string }
        quote:           { type: string }
        last_trade_at:
          type: string
          format: date-time
          description: "Most recent trade timestamp for this pair (minute-precision for in-24h-active pairs via prices_1m; daily bucket-start fallback for pairs idle 24h+ but active in the 14d recency window). Use for staleness computations."
        bucket_close_at:
          type: string
          format: date-time
          description: "Start-of-day UTC of the prices_1d bucket the pair was last active in. Aligns to midnight UTC by construction. Pre-2026-05-27 (F-0065) this value was incorrectly returned as `last_trade_at`; the field is preserved for callers that want the daily bucket reference, but do NOT use it for staleness."
        trade_count_24h: { type: integer, description: "Activity count in the trailing 24h window." }
        volume_24h_usd:
          type: string
          nullable: true
          description: "Trailing-24h USD volume summed from prices_1m. Decimal string. Null when no USD-equivalent trades."
        last_price:
          type: string
          nullable: true
          description: "Most recent quote-per-base price observed for this pair (cross-source) within the trailing 24h. Decimal string. Null when no recent prices_1m bucket has a non-null last_price."
        first_trade_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            The pair's first recorded daily bucket — "since inception =
            first recorded trade" (RFP), queryable per market. Present
            only with `?include=inception`; day precision.
        volume_history_24h:
          type: array
          description: "Per-hour USD-volume buckets for the trailing 24h, oldest → newest, zero-filled server-side (always 24 entries when present). Populated only when the request sets `?include=sparkline`; absent otherwise."
          items:
            type: object
            properties:
              hour:       { type: string, format: date-time }
              volume_usd: { type: string, description: "Decimal string." }
            required: [hour, volume_usd]
      required: [base, quote, last_trade_at, bucket_close_at, trade_count_24h]

    # /v1/pools row — MarketRow plus the `source` (venue) dimension:
    # the same physical pair traded on two DEXes appears as two rows,
    # each with its own per-venue last_price. Mirrors
    # internal/api/v1/markets.go `Pool`.
    PoolRow:
      type: object
      properties:
        source:          { type: string, description: "DEX source name (soroswap, phoenix, aquarius, comet, sdex)." }
        base:            { type: string }
        quote:           { type: string }
        last_trade_at:   { type: string, format: date-time }
        trade_count_24h: { type: integer }
        volume_24h_usd:  { type: string, nullable: true, description: "Decimal string. Null when no USD-equivalent trades." }
        last_price:      { type: string, nullable: true, description: "Most recent quote-per-base price observed on THIS venue. Decimal string." }
      required: [source, base, quote, last_trade_at, trade_count_24h]

    MarketsEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/MarketRow" }
            pagination: { $ref: "#/components/schemas/Pagination" }
          required: [data]

    Source:
      type: object
      properties:
        name:               { type: string, description: "Stable connector identifier (matches canonical.Trade.Source)." }
        class:              { type: string, enum: [exchange, aggregator, oracle, authority_sanity, bridge, lending, router], description: "Top-level taxonomy. Drives the aggregator's class filter — only `exchange` contributes to VWAP by default." }
        subclass:           { type: string, enum: [dex, cex, fx], description: "Refines `class=exchange` for UIs that group venues. Empty (omitted) for non-exchange classes." }
        include_in_vwap:    { type: boolean, description: "Whether this source's trades contribute to VWAP." }
        paid:               { type: boolean, description: "Whether this connector requires a paid licence / API key." }
        backfill_available: { type: boolean, description: "Whether the source supports historical backfill via its native API." }
        backfill_safe:      { type: boolean, description: "Whether `stellarindex-ops backfill` will run on this source. On-chain Soroban sources start `false` and only flip `true` after a per-WASM-hash audit (`docs/operations/wasm-audits/`); off-chain CEX/FX sources are always `true`." }
        default_weight:     { type: integer, description: "Default per-source weight for weighted-aggregation paths (when those land)." }
        on_chain:           { type: boolean, description: "Whether the source observes the Stellar network directly (dispatcher-path ingest) rather than reading an off-chain vendor API. `false` for CEX / FX / aggregators / Chainlink (an Ethereum oracle). The explorer's Stellar-network surfaces filter on this." }
        trade_count_24h:    { type: integer, description: "Trailing-24h trade count for this source. Populated only when the request used `?include=stats`; absent (omitted) otherwise." }
        volume_24h_usd:     { type: string, description: "Trailing-24h USD volume for this source. Decimal string. Populated only with `?include=stats`; absent otherwise (empty when the source had no priced trades)." }
        markets_count_24h:  { type: integer, description: "Distinct (base, quote) pairs this source traded in the trailing 24h. Populated only with `?include=stats`; absent otherwise." }
        volume_history_24h:
          type: array
          description: "Per-hour USD-volume buckets for the trailing 24h, oldest → newest, zero-filled (24 entries). Populated only when the request includes `sparkline` (e.g. `?include=stats,sparkline`)."
          items:
            type: object
            properties:
              hour:       { type: string, format: date-time }
              volume_usd: { type: string, description: "Decimal string." }
            required: [hour, volume_usd]
        volume_history_7d:
          type: array
          description: "Same per-hour shape over the trailing 7 days (168 buckets). Populated only when the request includes `sparkline7d`."
          items:
            type: object
            properties:
              hour:       { type: string, format: date-time }
              volume_usd: { type: string, description: "Decimal string." }
            required: [hour, volume_usd]
      required: [name, class, include_in_vwap, paid, backfill_available, backfill_safe, default_weight, on_chain]

    Methodology:
      type: object
      properties:
        version:
          type: string
          description: On-disk shape version. Bumps on breaking changes.
        aggregation:
          type: object
          properties:
            price_method:
              type: string
              enum: [vwap]
              description: Formula for the headline served price.
            outlier_filter:
              type: object
              properties:
                endpoint:
                  type: string
                  description: API surface this rule applies to. Empty = global default.
                default_sigma:
                  type: number
                  description: σ threshold applied when caller doesn't override via `?outlier_sigma=`.
                note:
                  type: string
              required: [default_sigma]
            stablecoin_fiat_proxy:
              type: array
              items:
                type: object
                properties:
                  asset_id: { type: string }
                  pegs_to:  { type: string }
                required: [asset_id, pegs_to]
            closed_bucket_window_seconds:
              type: integer
              description: Boundary granularity rate endpoints snap "now" to. Per ADR-0015.
          required: [price_method, outlier_filter, stablecoin_fiat_proxy, closed_bucket_window_seconds]
        source_classes:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                enum: [exchange, aggregator, oracle, authority_sanity, lending, router]
              contributes_to_vwap: { type: boolean }
              description:         { type: string }
            required: [name, contributes_to_vwap, description]
        sources:
          type: array
          description: Same data as `/v1/sources` (without live trade-count stats) — included so a transparency consumer can verify the policy in one round trip.
          items: { $ref: "#/components/schemas/Source" }
        references:
          type: array
          items:
            type: object
            properties:
              id:    { type: string, description: "ADR identifier (e.g. ADR-0007)." }
              title: { type: string }
              url:   { type: string, description: "Repo-relative path." }
            required: [id, title, url]
      required: [version, aggregation, source_classes, sources, references]

    MethodologyEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Methodology" }
          required: [data]

    SourcesEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Source" }
          required: [data]

    PairsEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/MarketRow" }
          required: [data]

    # Oracle passthrough
    OraclePriceEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: object
              properties:
                asset:     { type: string }
                price:     { type: string }
                timestamp: { type: string, format: date-time }
              required: [asset, price, timestamp]
          required: [data]

    OraclePricesEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items:
                type: object
                properties:
                  price:     { type: string }
                  timestamp: { type: string, format: date-time }
                required: [price, timestamp]
          required: [data]

    # Account
    Account:
      type: object
      properties:
        user:
          type: object
          additionalProperties: true
          description: "Magic-link session caller's user info (id, email, display_name, role, is_staff, …) — present on cookie-session /v1/account/me responses only (account.go AccountUser)."
        account:
          type: object
          additionalProperties: true
          description: "Session caller's parent account (id, name, slug, tier, status) — present on cookie-session responses only (account.go AccountInfo)."
        key_id:           { type: string }
        label:            { type: string }
        key_prefix:
          type: string
          description: |
            First 12 characters of the plaintext key (e.g.
            `sip_4f9c1d8b`). Safe to log; customers use it to
            match dashboard rows to entries in their secret
            manager. Empty on legacy keys minted before the
            prefix feature shipped.
        tier:             { type: string, enum: [anonymous, apikey, partner] }
        rate_limit_per_min: { type: integer }
        created_at:       { type: string, format: date-time }

    AccountEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Account" }
          required: [data]

    UsageRow:
      type: object
      properties:
        date:      { type: string, format: date }
        requests:  { type: integer }
        errors:    { type: integer }
        throttled: { type: integer }

    UsageEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/UsageRow" }
          required: [data]

    KeyCreatedEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: object
              properties:
                key_id:     { type: string }
                plaintext:  { type: string, description: "Shown once. Store it now." }
                key_prefix:
                  type: string
                  description: |
                    First 12 characters of the plaintext (e.g.
                    `sip_4f9c1d8b`). Safe to display in logs and
                    dashboards; customers use it to identify
                    which key matches a row in their secret
                    manager. Same value also returned by GET
                    `/v1/account/keys`.
                label:      { type: string }
              required: [key_id, plaintext, label]
          required: [data]

    SEP10Challenge:
      type: object
      properties:
        transaction:
          type: string
          description: Base64-encoded XDR of the unsigned challenge transaction. Field name per SEP-10 §3.2.
        network_passphrase:
          type: string
          description: Stellar network the challenge was crafted for (echoed so clients verify before signing). Per SEP-10 §3.2.
        issued_at:  { type: string, format: date-time }
        valid_until: { type: string, format: date-time }
      required: [transaction, network_passphrase, issued_at, valid_until]

    SEP10Token:
      type: object
      properties:
        token:
          type: string
          description: JWT bearer the client sends as `Authorization` on subsequent requests.
        expires_at: { type: string, format: date-time }
        account:
          type: string
          description: G-strkey of the authenticated account; convenience for clients that don't decode the JWT.
      required: [token, expires_at, account]

    # Error (RFC 9457)
    Problem:
      type: object
      description: |
        RFC 9457 problem details. Custom extension fields are snake_case.
        `request_id` echoes the X-Request-ID response header so a
        client bug report containing only the body is enough for
        support to correlate against server logs.
      properties:
        type:       { type: string, format: uri }
        title:      { type: string }
        status:     { type: integer }
        detail:     { type: string }
        instance:   { type: string }
        request_id: { type: string, description: "Mirror of the X-Request-ID response header." }
      required: [type, title, status]
