# 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.19.0"
  summary: The Stellar network — explorer, history, supply, analytics, and prices.
  description: |
    The Stellar Index API exposes the Stellar network as one
    consistent read surface: ledgers, transactions, operations and
    contracts; the asset catalogue with supply and holders; complete
    since-inception history; MEV / anomaly / divergence analytics;
    and normalised, VWAP-aggregated prices for every classic and
    SEP-41 Soroban asset.

    Design intent: see
    https://github.com/Stellar-Index/StellarIndex/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: admin
    description: |
      Operator-only surfaces. Every route requires an operator-tier
      credential (staff-issued via stellarindex-ops; never granted
      to public callers) and is audit-logged.
  - 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:
      operationId: getLiveness
      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/HealthEnvelope" }

  /readyz:
    get:
      operationId: getReadiness
      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" }

  /livez/lake:
    get:
      operationId: getLakeLiveness
      tags: [health]
      summary: Lake-critical health — should this instance receive lake-route traffic?
      description: |
        The complement to `/v1/readyz` for the ClickHouse-lake-backed
        routes (explorer browse/search, token supply and holders, pool
        reserves and TVL, order books — the ~21 routes that 503 when the
        lake is down). `/v1/readyz` deliberately treats ClickHouse as
        NON-critical so a lake outage degrades rather than un-readies the
        pricing surface — but that same 200 would keep a lake-dead
        instance in a load balancer's pool for routes it can no longer
        serve. This probe is lake-critical: 200 iff the registered
        ClickHouse checker pings; 503 when it fails OR when no lake is
        wired at all (a lake-less deployment must never receive
        lake-route traffic, so absence fails closed).

        Point lake-route load-balancer monitors here and pricing
        monitors at `/v1/readyz` (ADR-0050 §7.3 — the per-tier health
        split the multi-region design requires).
      security: []
      responses:
        "200":
          description: The lake is reachable — route lake traffic here.
          content:
            application/json:
              example:
                data:
                  status: ok
                as_of: '2026-08-21T22:40:00Z'
        "503":
          description: |
            The lake is unreachable (`data.status` = `lake-unready`,
            with the ping error in `data.detail`) or this deployment has
            no ClickHouse wired at all (`lake-absent`). Either way, do
            not route lake traffic here; pricing routes may still be
            healthy — check `/v1/readyz`.
          content:
            application/json:
              example:
                data:
                  status: lake-unready
                  detail: "dial tcp 127.0.0.1:9300: connect: connection refused"
                as_of: '2026-08-21T22:40:00Z'

  /version:
    get:
      operationId: getVersion
      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:
      operationId: getStatus
      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
                    p95_target_ms: 200
                    p99_target_ms: 500
                  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
                  incidents_status: ok
                as_of: "2026-05-05T15:09:00.119Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }

  /status/notices:
    get:
      operationId: listStatusNotices
      tags: [health]
      summary: Active operator-posted status banners.
      description: |
        The public list of ACTIVE, human-authored status banners an
        operator posted for a maintenance window or an unfolding
        incident (admin Phase 1.5 incident tooling). The status page
        renders these alongside the Alertmanager-derived `incidents`
        block on GET /v1/status. Distinct from GET /v1/incidents, which
        serves the historical post-mortem corpus.

        Anonymous-friendly. Returns `{"notices":[],"count":0}` (never
        null) when no banners are active or the notice store is unwired.
      security: []
      responses:
        "200":
          description: Active status-notice list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusNoticesEnvelope" }

  /assets:
    get:
      operationId: listAssets
      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:
                - kind: stellar_asset
                  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
                - kind: stellar_asset
                  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:
      operationId: listExternalAssets
      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:
                - kind: stellar_asset
                  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
                - kind: stellar_asset
                  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:
      operationId: getExternalAsset
      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:
                  kind: catalogue
                  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:
      operationId: listVerifiedAssets
      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:
      operationId: getAsset
      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, then to **classic-asset slug
        resolution**: every classic asset's public slug is its
        fully-qualified asset_id (`CODE-GFULLISSUER`), matched
        case-insensitively — so a lowercased or case-mangled form of
        the canonical id still resolves. The slug is deliberately
        NEVER an abbreviation: issuer-prefix forms are vanity-
        grindable (~2^32 for 8 chars), and the full form is
        self-certifying. A path that is none of the above returns
        400.
      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:
                  kind: stellar_asset
                  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:
                # ADR-0042 LC-040: the two branches are discriminated by
                # `data.kind` ("stellar_asset" | "catalogue"), but that
                # property lives one level below the oneOf member (inside
                # each envelope's `data`, not on the envelope itself) —
                # OpenAPI 3.x `discriminator.propertyName` is conventionally
                # read directly off the resolved oneOf-member schema, and
                # spec tooling (validated against this repo's Scalar
                # renderer, SCALAR_VERSION pinned in scripts/dev/docs-api.sh)
                # does not reliably resolve a nested `data.kind` through an
                # allOf-flattened envelope. Rather than ship a
                # discriminator block that mis-renders, this stays a plain
                # oneOf: each branch's `kind` is in its `required` list
                # (see AssetEnvelope→Asset, GlobalAssetEnvelope→
                # GlobalAssetView) and the dispatch rule is documented in
                # prose above — the same mechanical disambiguation
                # pkg/client/spec_contract_test.go's envelopeRef already
                # relies on. Consumers: branch on `data.kind`.
                oneOf:
                  - $ref: "#/components/schemas/AssetEnvelope"
                  - $ref: "#/components/schemas/GlobalAssetEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /assets/{asset_id}/metadata:
    get:
      operationId: getAssetMetadata
      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:
      operationId: getAssetSupply
      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 — derived
        deterministically from the asset + network passphrase (valid even
        before the SAC is deployed), with the operator's configured SAC
        wrappers as an explicit override; `native` / `XLM` returns the ledger
        header's `total_coins` (`source=ledger_total_coins`), since XLM has no
        SAC mint/burn events. Only ids with no contract at all (`fiat:*`)
        return 404.

        Freshness (ADR-0041): `as_of_ledger` is the lake watermark the read
        is fresh to; `flags.stale` fires when the watermark's close time
        trails now by more than 300s.
      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_ledger: 63340102
                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:
      operationId: getAssetHolders
      tags: [assets]
      summary: Top holders of an asset by current 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` state per account (ADR-0038 Phase C).
        For issued assets the balance is the holder's trustline balance; for
        native XLM — which has no trustlines — it is the account's own XLM
        balance, and `holder_count` is the exact number of funded accounts.
        `asset_id` is the canonical form (`CODE-ISSUER`, or `native`; the
        `crypto:XLM` alias serves the native board, echoed as
        `asset: "native"`). Balances are strings (ADR-0003). Coverage grows
        with the entry-change capture window; full once the Phase-C backfill
        lands.

        Freshness (ADR-0041): `as_of_ledger` is the lake watermark the read
        is fresh to; `flags.stale` fires when the watermark's close time
        trails now by more than 300s.
      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_ledger: 63340102
                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 }
                      as_of_ledger: { type: integer, format: int64, description: "Lake watermark this read is fresh to (ADR-0041). Omitted when no watermark reader is wired. Pairs with flags.stale." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /price:
    get:
      operationId: getPrice
      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 — with one
        deliberate exception: the **thin-market substance gate**. When
        the pair has an on-chain leg (native / classic / Soroban) and
        its trailing market activity is below the operator's serve
        floor (default: $1,000 USD volume, 20 distinct 1-minute
        buckets, and 6 hours of activity span within the trailing 24
        hours, summed across the pair's alias family), the endpoint
        WITHHOLDS the price and returns 404 with the distinct problem
        type `https://api.stellarindex.io/errors/price-withheld` —
        without consulting the fallback chain. On a permissionless DEX
        an asset's entire market can be authored by one participant
        for pennies, so an aggregated price claim for a substanceless
        market would be an attacker-controlled number. Integrators can
        branch on the problem `type`: `price-not-found` means "pair
        never observed", `price-withheld` means "pair observed, too
        thin to aggregate — read `/v1/observations`, `/v1/ohlc` or
        `/v1/history` (which still serve the raw market) and apply
        your own judgement". The same gate protects `/v1/price/tip`,
        `/v1/price/batch` (withheld rows are omitted, like misses),
        the SEP-40 oracle endpoints, and the `price_usd` enrichment on
        asset surfaces.
      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 VWAP bucket at-or-before `ts` — the cost-basis
        / PnL / tax-tooling lookup. The answer comes from the FINEST CAGG
        resolution that covers the instant: `prices_1m` for recent
        timestamps, coarser bars (down to daily, back to 2015) for older
        ones. `observed_at` is the BUCKET's close time, never `ts`, and
        `window_seconds` reports the resolution used (60 for a 1-minute
        bar, 86400 for a daily bar) — so callers see exactly how far the
        nearest observation was and at what granularity. A nearest bucket
        more than 24 hours before `ts` is a 404 (the endpoint refuses to
        fabricate continuity across dead markets). When the literal pair
        (and its aliases) has no bucket and the quote is `fiat:USD`, the
        lookup retries each operator-declared USD-pegged classic (the
        same stablecoin-proxy chain as /v1/price) and returns the peg's
        bucket with `flags.triangulated=true`, echoing the requested
        quote. Current price: /v1/price or /v1/price/tip. Multi-horizon
        change: /v1/price/changes.
      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/changes:
    get:
      operationId: getPriceChanges
      tags: [price]
      summary: Multi-horizon price change (1h / 24h / 7d / 30d in one call)
      description: >-
        The current closed price plus the signed percentage change over
        1h, 24h, 7d, and 30d — the wallet/portfolio delta strip in a
        single request (RFP §6). Each horizon's reference is the closed
        VWAP at-or-before `now − horizon`, resolved by the SAME
        point-in-time engine as /v1/price/at (finest CAGG resolution that
        covers the instant; daily bars reach back to 2015), so long
        horizons still answer for pairs whose minute-level history has
        aged out of the served tier. A horizon with no data that far back
        (a young pair, or a market that predates the window) is returned
        with all fields `null` and `available: false` — NEVER an error,
        so a fresh listing still returns its 1h/24h moves. Each horizon
        carries `reference_at` + `resolution` so callers see exactly
        which bucket (and at what granularity) the delta was measured
        against. A 404 only when the pair has no CURRENT price to anchor
        on. `quote` defaults to `fiat:USD`; when no direct fiat:USD
        bucket exists the same stablecoin-proxy chain as /v1/price
        resolves it via an operator-declared USD peg (`flags.triangulated`).
      parameters:
        - { $ref: "#/components/parameters/AssetQuery" }
        - { $ref: "#/components/parameters/Quote" }
      responses:
        "200":
          description: Current price + per-horizon change.
          content:
            application/json:
              example:
                data:
                  asset_id: native
                  quote: fiat:USD
                  current_price: '0.20416'
                  current_price_type: vwap
                  observed_at: '2026-07-05T00:00:00Z'
                  resolution: 1m
                  1h:
                    change_pct: '+0.42'
                    reference_price: '0.20330'
                    reference_at: '2026-07-04T23:00:00Z'
                    resolution: 1m
                    available: true
                  24h:
                    change_pct: '+3.62'
                    reference_price: '0.19703'
                    reference_at: '2026-07-04T00:00:00Z'
                    resolution: 1m
                    available: true
                  7d:
                    change_pct: '-1.08'
                    reference_price: '0.20639'
                    reference_at: '2026-06-28T00:00:00Z'
                    resolution: 1h
                    available: true
                  30d:
                    change_pct: null
                    reference_price: null
                    reference_at: null
                    resolution: null
                    available: false
                as_of: '2026-07-05T00:00:12.481Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/PriceChangesEnvelope" }
        "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:
    get:
      operationId: getPriceTip
      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.

        The thin-market substance gate applies here exactly as on
        `/v1/price` (see that endpoint's description): a pair with an
        on-chain leg whose trailing market is below the serve floor is
        a 404 with problem type `…/errors/price-withheld` — freshness
        is this surface's promise, but an aggregated claim over a
        market one participant authored is still not publishable.
      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:
      operationId: streamPriceTip
      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 }
              example: |
                id: 0198a41f2b3c0001
                event: tip_update
                data: {"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}}

                :keepalive

                id: 0198a41f2b3c0002
                event: tip_update
                data: {"data":{"asset_id":"native","quote":"fiat:USD","price":"0.2042701122","price_type":"vwap","observed_at":"2026-07-03T22:37:38.051902614Z","window_seconds":30},"as_of":"2026-07-03T22:37:38.052918217Z","sources":["coinbase","bitstamp"],"flags":{"stale":false,"reduced_redundancy":false,"triangulated":false,"divergence_warning":false,"divergence_checked":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/batch:
    get:
      operationId: getPriceBatch
      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:
      operationId: getPriceBatchBulk
      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:
              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" }

  /observations:
    get:
      operationId: listObservations
      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).
        - **Freshness is PER OBSERVATION, not envelope-level (W8.11).**
          `flags.stale` is **always** `false` here (there is no aggregation
          contract to fall short of), and the envelope `as_of` is the
          RESPONSE time, not the data's age. A source can be arbitrarily
          stale — a venue that last traded the pair weeks ago still returns
          that last trade. Assess freshness from each observation's own
          trade timestamp (`ts`); do NOT infer it from `as_of` or
          `flags.stale` on this endpoint.
        - 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:
      operationId: streamObservations
      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 }
              example: |
                id: 0198a41f6d900001
                event: observations_update
                data: {"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}}

                :keepalive

                id: 0198a41f6d900002
                event: observations_update
                data: {"data":[],"as_of":"2026-07-03T22:42:31.870004112Z","sources":[],"flags":{"stale":false,"reduced_redundancy":false,"triangulated":false,"divergence_warning":false,"divergence_checked":false,"single_source":false}}

        "400":        { $ref: "#/components/responses/BadRequest" }
        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /price/stream:
    get:
      operationId: streamPrices
      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, window) triple receives byte-identical payloads
        — the same cross-region consistency guarantee `/v1/price`
        exposes.

        The aggregator publishes one bucket series **per window**
        (standard windows: 300 / 3600 / 86400 seconds); a connection
        follows exactly one series, selected by `?window_seconds=`
        (default 300), so consecutive events on a stream are always
        comparable. Alias spellings of the same asset (`native`,
        `crypto:XLM`, the XLM SAC) are subscribed together — either
        spelling receives the pair's publishes.

        - Heartbeats every 15 s as comment lines.
        - Resume after disconnect via `Last-Event-ID` (header preferred,
          `?last_event_id=` fallback).
        - Events carry the `/v1/price` envelope shape (`data` +
          `as_of`); `flags` / `sources` are present only when the
          publishing path evaluated them — absent flags mean "not
          evaluated", never "fresh".
        - URL discipline: `?granularity=` returns 400 — bucket series
          are selected by `window_seconds`; use
          `/v1/history/since-inception` for chart 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: window_seconds
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 300 }
          description: >-
            Which closed-bucket window series to follow, in seconds.
            The standard aggregator windows are 300, 3600, and 86400;
            a window the aggregator doesn't publish yields a silent
            (heartbeat-only) stream.
        - 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 }
              example: |
                id: 0198a4203f100001
                event: price_update
                data: {"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}}

                :keepalive

                id: 0198a4203f100002
                event: price_update
                data: {"data":{"asset_id":"native","quote":"fiat:USD","price":"0.159701882234","price_type":"vwap","observed_at":"2026-05-05T14:36:00Z","window_seconds":300},"as_of":"2026-05-05T14:36:00.417Z","flags":{"stale":false,"reduced_redundancy":false,"triangulated":false,"divergence_warning":false}}

        "400":        { $ref: "#/components/responses/BadRequest" }
        "429":        { $ref: "#/components/responses/RateLimited" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /history:
    get:
      operationId: getHistory
      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'
                  base_decimals: 7
                  quote_decimals: 7
                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:
      operationId: getHistorySinceInception
      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:
      operationId: getChart
      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 a time-weighted series from the
        `twap_1h` / `twap_1d` continuous aggregates. TWAP is
        time-weighted at 1-minute resolution (each minute's mean price
        counts once, regardless of how many trades printed in it), so
        it is served only at 1h or 1d granularity — the requested
        `granularity` is snapped onto the nearer of the two and the
        response reports the grain actually served. Single-bar TWAP
        over an arbitrary window stays on `/v1/twap` (LOCF Δt-weighted
        compute from raw trades).

        Fiat:fiat pairs are served from the daily `fx_quotes`
        reference-rate series (sub-daily granularities replicate the
        daily bar). Cross-fiat pairs where neither side is USD
        (e.g. `asset=fiat:EUR&quote=fiat:JPY`) are triangulated on
        read through both USD legs — `rate_usd[quote] /
        rate_usd[base]` per shared day, same algebra as /v1/price's
        cross-rate fallback — and stamp `flags.triangulated=true`.
      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:
      operationId: getOhlc
      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 hit the per-request cap
           (10000 trades) and the bar reflects only the chronologically
           **last** N trades of the window — the reader drops the
           OLDEST rows under the limit, so `open`/`high`/`low` may not
           be the true window values (`close` is unaffected). Narrow
           the window and retry for an exact bar.
           `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:
      operationId: getVwap
      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
        hits the server's per-request cap (10000 trades) the
        response carries `truncated: true` — the price reflects
        only the chronologically **last** N trades of the window,
        not the full window: the reader drops the OLDEST rows
        under the limit so a truncated result always runs up to
        the window end. Narrow the window and retry for an exact
        VWAP.
      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 in the window were filtered as outliers by the
            requested `outlier_sigma`; relax the threshold or omit it.
          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:
      operationId: getTwap
      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:
      operationId: getOracleLatest
      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:
      operationId: listPools
      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" }

  /pools/reserves:
    get:
      operationId: getPoolReserves
      tags: [markets]
      summary: Current per-pool-contract AMM reserves + constant-product depth.
      description: |
        CURRENT reserve state for AMM pool contracts, decoded straight
        from each pool's Soroban contract storage in the certified lake
        (ADR-0039, same mechanism as
        `/v1/lending/pools/{pool}/reserves`) — plus a depth table
        derived from those reserves under the constant-product model.

        HONEST-COVERAGE CONTRACT: reserves are served only for venues
        whose pool-storage layout has been verified against the lake.
        Today that is **Soroswap only** (every registered SoroswapPair
        contract). Phoenix / Aquarius / Comet pool layouts are not yet
        verified, so those venues are refused explicitly (400 on
        `?source=`), never served as guesses. Historical reserve series
        are not available anywhere in the API: ingest does not persist
        reserves (Soroswap `SyncEvent`s are consumed transiently at
        trade decode), so only current state is honestly servable.

        Reserves are exact i128 base-unit decimal strings (ADR-0003).
        `as_of_ledger` stamps the ledger of the pool's last state
        change — reserves are current as of that ledger and unchanged
        since. Separately, the envelope's `flags.stale` reflects lake
        freshness (ADR-0041): it fires when the lake watermark's close
        time trails now by more than 300s (a wedged galexie→ClickHouse
        sink makes every reserve read stale regardless of per-pool
        last-change ledger). The `depth` table is a MODEL-DERIVED estimate (labelled
        via `model: constant_product`, x·y=k with the 0.3% fee on
        input): per slippage tier and direction, the largest input
        whose AVERAGE execution price stays within the tier of the mid
        price, and its output. It assumes no other trade lands first —
        it is not an order book. Pools whose contract-storage entry
        isn't captured in the lake are absent from the listing (absent,
        never zero); a pool with an empty side reports its reserves but
        no mid price / depth.
      parameters:
        - name: pool
          in: query
          schema: { type: string }
          description: |
            Optional. A pool (pair) contract C-strkey; restricts the
            response to that pool. 404 when the contract is not a
            registered Soroswap pair — unknown pools are refused, not
            silently empty.
        - name: source
          in: query
          schema: { type: string, enum: [soroswap] }
          description: |
            Optional venue filter. `soroswap` is the only accepted
            value today; other venues return 400 naming the coverage
            limit.
      responses:
        "200":
          description: Per-pool current reserves + depth, sorted by pool contract id.
          content:
            application/json:
              example:
                data:
                - pool: CDYRXTIUEUNJ3SVAKVWKKRK3CZVIY4WOG6TAQU27R777ZHNOKNCO26NL
                  source: soroswap
                  model: constant_product
                  fee_bps: 30
                  as_of_ledger: 62941880
                  token0:
                    contract: CBRP2VD3CZLEQIQZ4JMBXGA5AC2U6JE26YU5CCIOICIZCVWPGBO2QRUB
                    symbol: YBX
                    decimals: 7
                    reserve: '172458996'
                  token1:
                    contract: CBY4MSZXK5L4HDMJHDXQLNLOA5MM5BIGCHQYMRG7ZAFY34UNU4UXPEJJ
                    symbol: LMNR
                    decimals: 7
                    reserve: '603291773585'
                  mid_price_0_in_1: '3498.175146427270166874'
                  mid_price_1_in_0: '0.000285863331063143'
                  depth:
                  - slippage_pct: '0.5'
                    token0_in: { max_input: '347694', output: '1210213039' }
                    token1_in: { max_input: '1216295668', output: '345955' }
                  - slippage_pct: '1'
                    token0_in: { max_input: '1223076', output: '4235748731' }
                    token1_in: { max_input: '4278535014', output: '1210845' }
                  - slippage_pct: '2'
                    token0_in: { max_input: '3000637', output: '10286818733' }
                    token1_in: { max_input: '10496755727', output: '2940624' }
                as_of: '2026-07-05T12:00:00.000000000Z'
                sources: [soroswap]
                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/PoolReservesRow" }
                    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" }

  /liquidity-pools:
    get:
      operationId: listLiquidityPools
      tags: [markets]
      summary: Current native (CAP-38) liquidity-pool two-sided reserves + depth.
      description: |
        CURRENT two-sided reserves for Stellar's protocol-native
        (CAP-38) constant-product liquidity pools, read straight from
        the `liquidity_pool` LedgerEntry in the certified lake's
        current-state table (ADR-0039, the same read-time-decode design
        as `/v1/pools/reserves` and
        `/v1/lending/pools/{pool}/reserves`) — plus a depth table under
        the constant-product model.

        Native pools are DISTINCT from Soroban AMM protocols (Soroswap,
        Aquarius, Phoenix, Comet — see `/v1/pools/reserves`): they are
        built into the ledger by CAP-38 with no smart contract, and
        their reserves are TWO-sided on-chain (both `reserve_a` and
        `reserve_b` live in the entry). Unlike the Soroban AMM path —
        where reserves are consumed transiently at trade decode and
        persisted nowhere — the full per-pool reserve state is honestly
        available for every native pool. (The supply-verification path
        only records the operator-watched side of watched-asset pools;
        this endpoint reads both sides of every pool.)

        Reserves are exact base-unit (stroop) decimal strings (ADR-0003;
        classic assets are always 7 decimals). `as_of_ledger` stamps the
        ledger of the pool's last state change — reserves are current as
        of that ledger and unchanged since. The envelope's `flags.stale`
        reflects lake freshness (ADR-0041): it fires when the lake
        watermark's close time trails now by more than 300s. The `depth`
        table is a MODEL-DERIVED estimate (labelled `model:
        constant_product`, x·y=k with the pool's on-chain fee on input),
        not an order book. A pool with an empty side reports its
        reserves but no mid price / depth.

        Without `?pool=`, the response is the top-N native pools ranked
        by pool-share trustline count (number of liquidity providers —
        the only cross-pool-comparable size signal without USD pricing
        of arbitrary pool assets).
      parameters:
        - name: pool
          in: query
          schema: { type: string }
          description: |
            Optional. A native pool id — an L-strkey (SEP-23) or 32-byte
            hex. Restricts the response to that pool. 404 when the id is
            not a captured native pool; 400 when it is neither an
            L-strkey nor 32-byte hex.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
          description: |
            Optional (listing only, ignored with `?pool=`). Number of
            top-ranked pools to return. 1-100, default 25.
      responses:
        "200":
          description: Per-pool current two-sided reserves + depth.
          content:
            application/json:
              example:
                data:
                - pool: LBBQAH75JTPHENBF3UJEYMPQR4GQ43VZNPAE4H5XVUYS5YRBY6NFFKVM
                  pool_hex: 43001ffd4cde723425dd124c31f08f0d0e6eb96bc04e1fb7ad312ee221c79a52
                  model: constant_product
                  fee_bps: 30
                  as_of_ledger: 63356894
                  trustlines: 1
                  total_shares: '14133656465'
                  reserve_a:
                    asset: LibreDrone-GB7LCUIDT3C2DUOX4O2FSCCBH5NXIUJZ64YQ2N75N5POZRI4DA4AMGEE
                    decimals: 7
                    reserve: '109841733381'
                  reserve_b:
                    asset: deCent-GBVRVE6CCHJFZ6IFEKYRCRODKPPJHJFPCYOZILWADK4CEZB6DEXBYTI6
                    decimals: 7
                    reserve: '1868365732'
                  mid_price_a_in_b: '0.017009616240480621'
                  mid_price_b_in_a: '58.790274034527197162'
                  depth:
                  - slippage_pct: '0.5'
                    asset_a_in: { max_input: '221451759', output: '3747975' }
                    asset_b_in: { max_input: '3766809', output: '220344474' }
                  - slippage_pct: '1'
                    asset_a_in: { max_input: '778995707', output: '13117913' }
                    asset_b_in: { max_input: '13250418', output: '771205748' }
                  - slippage_pct: '2'
                    asset_a_in: { max_input: '1911151277', output: '31857790' }
                    asset_b_in: { max_input: '32507949', output: '1872928206' }
                as_of: '2026-07-06T12:00:00.000000000Z'
                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/LiquidityPoolReservesRow" }
                    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" }

  /lending/pools:
    get:
      operationId: listLendingPools
      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, "null"], 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:
      operationId: getLendingPoolReserves
      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.

        Freshness (ADR-0041): `as_of_ledger` is the lake watermark this
        current-state read is fresh to (the highest captured ledger at
        serve time); `flags.stale` fires when the watermark's close time
        trails now by more than 300s (a wedged sink).
      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_ledger: 63340102
                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, "null"], 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, "null"] }
                            borrowed_usd:    { type: [string, "null"] }
                            utilization_pct: { type: number, description: Borrowed/supplied, 0..100. }
                            borrow_apr:      { type: [number, "null"], 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, "null"], description: "Supply APR as a fraction. Null when the rate-model config is uncaptured." }
                      as_of_ledger: { type: integer, format: int64, description: "Lake watermark this current-state read is fresh to (ADR-0041) — the highest captured ledger at serve time. Omitted when no watermark reader is wired. Pairs with flags.stale." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /mev:
    get:
      operationId: listMevEvents
      tags: [analytics]
      summary: Auto-flagged MEV-event feed.
      description: |
        The MEV detectors' output, newest first. Detected kinds:

        - `arbitrage` — one taker trades a closed asset cycle (≥2 legs
          returning to a starting asset) inside a single transaction.
        - `wash_trade` — an account crossing its own offer
          (maker == taker), or two accounts repeatedly filling each
          other in both directions on one pair within a UTC day.
        - `sandwich` — one account's trades in two different
          transactions bracket another account's trade on the same
          pair within one ledger. Intra-ledger transaction ordering
          (tx_index, application order) comes from the raw ledger
          lake — the served rows don't carry it.
        - `oracle_sandwich` — one account's trades bracket an on-chain
          oracle update on an asset those trades touch, within one
          ledger (same lake-ordered signal).
        - `liquidation_cascade` — Blend liquidation-auction fills
          against distinct positions clustered within a short ledger
          window, with an on-chain oracle update inside the bracket.

        Each row's `detail` carries the kind's evidence (legs, roles,
        tx application order, oracle refs, …) plus a `note` stating
        exactly what is and is NOT claimed. Detection is positional /
        structural: front-run vs back-run intent is never asserted and
        `profit_usd` stays null. `oracle_deviation` remains reserved
        (not yet detected).

        **Read `sandwich` and `oracle_sandwich` candidates with care.**
        These detectors do not check trade DIRECTION, so a large
        majority of published candidates have both bracket legs on the
        same side — which cannot be a sandwich. The roles named in
        `detail.legs` (`bracket` / `victim`) describe POSITION in the
        ledger, not established behaviour, and the accounts listed are
        not accusations. Most notionals are sub-dollar.

        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_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, "null"], description: "Attacker-profit estimate; null for arbitrage (not estimated)." }
        "400": { $ref: "#/components/responses/BadRequest" }

  /anomalies:
    get:
      operationId: listAnomalies
      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); `?include=daily` adds the
        day×reason tally over the same window (`daily`, the calendar-
        heatmap block — `null` when not requested, `[]` when requested
        and the window holds zero freezes). 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 } }
        - { name: include, in: query, description: "`daily` → also return the per-(UTC day, reason) freeze tally over the same window.", schema: { type: string, enum: [daily] } }
      responses:
        "200":
          description: Freeze timeline + firing count + reason tally.
          content:
            application/json:
              example:
                data:
                  firing_count: 0
                  reason_tally: []
                  events: []
                  daily: null
                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, other] }
                            frozen_value:        { type: string }
                            recovered_at:        { type: [string, "null"], format: date-time }
                            recovered_at_ledger: { type: [integer, "null"], format: int64 }
                            firing:              { type: boolean }
                            detail:              { type: object }
                      daily:
                        type: [array, "null"]
                        description: >-
                          Per-(UTC day, reason) freeze tally over the same
                          window as reason_tally — only populated when
                          `?include=daily` was requested. `null` means "not
                          requested"; `[]` means "requested, zero freezes in
                          the window". Days with zero freezes carry no
                          entries.
                        items:
                          type: object
                          properties:
                            day:    { type: string, format: date }
                            reason: { type: string }
                            count:  { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }

  /divergence:
    get:
      operationId: getDivergenceBoard
      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" }

  /divergence/series:
    get:
      operationId: getDivergenceSeries
      tags: [analytics]
      summary: Δ% time-series for one (pair, reference).
      description: |
        The history companion to the `/v1/divergence` board: the
        bucketed Δ% series for ONE (asset, quote, reference) triple
        from `divergence_observations`. Each point is the LAST
        observation inside its bucket (last-value downsampling — the
        board semantics, not an average); `firing` is true when ANY
        observation in the bucket breached its threshold, so a brief
        breach never disappears into a bucket. `bucket_seconds`
        reports the effective resolution (1d → 5 min, 7d → 30 min,
        30d → 2 h; every response is ≤ ~360 points).

        `threshold_pct` is the operator's configured alert threshold
        (`divergence.threshold_pct`) — the band a chart shades;
        omitted when the deployment has none configured (draw no
        band). `?pair=` is `<asset_id>~<quote_id>` (the markets slug
        convention); `?reference=` one of the board's reference
        names; `?days=` ∈ {1, 7, 30} (default 7) — other values
        return 400. 200 + empty `points` when the reader isn't wired
        or the triple has no observations in the window.
      parameters:
        - { name: pair, in: query, required: true, description: "`<asset_id>~<quote_id>`, e.g. `crypto:BTC~fiat:USD`.", schema: { type: string } }
        - { name: reference, in: query, required: true, description: "External reference to plot against.", schema: { type: string, enum: [chainlink, coingecko, reflector-cex, reflector-fx, reflector-dex, redstone, band] } }
        - { name: days, in: query, description: "Trailing window; whitelisted to 1, 7 or 30 (default 7).", schema: { type: integer, enum: [1, 7, 30], default: 7 } }
      responses:
        "200":
          description: Bucketed divergence series for the triple.
          content:
            application/json:
              example:
                data:
                  asset_id: crypto:BTC
                  quote_id: fiat:USD
                  reference: coingecko
                  days: 7
                  bucket_seconds: 1800
                  threshold_pct: 5
                  points:
                  - t: '2026-07-29T12:00:00Z'
                    delta_pct: '-0.104909'
                    our_price: '62543.07358731602'
                    ref_price: '62608.75585288'
                  - t: '2026-07-29T12:30:00Z'
                    delta_pct: '6.412000'
                    our_price: '66623.11'
                    ref_price: '62608.75'
                    firing: true
                as_of: '2026-07-29T12:31:03.478937272Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    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] }
                      days:           { type: integer }
                      bucket_seconds:
                        type: integer
                        description: >-
                          Downsampling bucket width. Each point is the last
                          observation inside its bucket; render the series at
                          this resolution, not as raw ticks.
                      threshold_pct:
                        type: number
                        description: >-
                          The operator's divergence alert threshold (percent) —
                          the same number the worker fires on. Omitted when
                          unconfigured; draw no band in that case.
                      points:
                        type: array
                        items:
                          type: object
                          properties:
                            t:         { type: string, format: date-time }
                            delta_pct: { type: string }
                            our_price: { type: string }
                            ref_price: { type: string }
                            firing:
                              type: boolean
                              description: >-
                                True when ANY observation in the bucket breached
                                its threshold at observation time. Omitted when
                                false.
        "400": { $ref: "#/components/responses/BadRequest" }

  /oracle/streams:
    get:
      operationId: listOracleStreams
      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:
      operationId: listMarkets
      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:
      operationId: getMarketSources
      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, "null"]
                                  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:
      operationId: listIssuers
      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:
      operationId: getIssuer
      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, org_verified]
                        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, "null"] }
                          auth_revocable: { type: [boolean, "null"] }
                          auth_immutable: { type: [boolean, "null"] }
                          auth_clawback: { type: [boolean, "null"] }
                          sep1_resolved_at: { type: [string, "null"], format: date-time }
                          sep1_payload: { type: [object, "null"], additionalProperties: true }
                          creation_ledger: { type: [integer, "null"] }
                          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:
      operationId: getContractTransfers
      # x-stability: see the cross-reference note at /ledgers below —
      # this whole explorer/operator surface (ADR-0042) is annotated
      # the same way and kept 1:1 with spec_contract_test.go's
      # uncoveredOperations "explorer surface" entries.
      x-stability: experimental
      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
                  decimals: 7
                  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, decimals, transfers]
                        properties:
                          contract_id: { type: string }
                          count: { type: integer }
                          limit: { type: integer }
                          from: { type: string }
                          to: { type: string }
                          decimals:
                            type: integer
                            description: |
                              The token contract's on-chain `decimals()`, read
                              from its captured instance metadata — the divisor
                              exponent for every `amount` in this response
                              (display value = amount / 10^decimals). Falls back
                              to 7 (the SAC/classic default) when the contract's
                              decimals declaration isn't derivable from the lake.
                          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:
      operationId: getEntityChanges
      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: string, description: "money (price/market-cap) — JSON string per INV-2" }
                          h1_value: { type: [string, "null"], description: "money — JSON string per INV-2" }
                          h1_delta_pct: { type: [number, "null"] }
                          h24_value: { type: [string, "null"], description: "money — JSON string per INV-2" }
                          h24_delta_pct: { type: [number, "null"] }
                          d7_value: { type: [string, "null"], description: "money — JSON string per INV-2" }
                          d7_delta_pct: { type: [number, "null"] }
                          d30_value: { type: [string, "null"], description: "money — JSON string per INV-2" }
                          d30_delta_pct: { type: [number, "null"] }
                          ath_value:
                            type: [string, "null"]
                            description: >-
                              Highest observed value since this entity entered the index —
                              NOT an all-time high over the asset's full history. money —
                              JSON string per INV-2.


                              The rollup scans a rolling ~30-day window, but the stored
                              value is merged with GREATEST on every refresh, so it
                              accumulates from the moment tracking began and never decays.
                              It therefore reaches back further than 30 days (the oldest
                              extreme on the reference deployment is ~80 days) but does not
                              reach back before the entity was first indexed, and it is not
                              backfilled from history.


                              Because the merge is monotonic, a single bad print is
                              permanent: an extreme recorded before a data-quality fix
                              landed stays until the row is rebuilt. Treat it as
                              "high-water mark observed by this index", not as a market
                              all-time high.
                          ath_at:
                            type: [string, "null"]
                            format: date-time
                            description: Observation time of ath_value.
                          atl_value:
                            type: [string, "null"]
                            description: >-
                              Lowest observed value since this entity entered the index —
                              NOT an all-time low over the asset's full history. money —
                              JSON string per INV-2. Same accumulation and
                              single-bad-print caveats as ath_value, via LEAST.
                          atl_at:
                            type: [string, "null"]
                            format: date-time
                            description: Observation time of atl_value.
                          streak_direction:
                            type: [string, "null"]
                            enum: [up, down, flat]
                          streak_days: { type: [integer, "null"] }
                          acceleration:
                            type: [string, "null"]
                            enum: [increasing, flat, decreasing]
                    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:
      operationId: getDiagnosticCursors
      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:
      operationId: getDiagnosticIngestion
      x-stability: experimental
      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
                    dirty: 'false'
                    go_version: go1.25.10
                  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
                    ranges_running: 0
                    ranges_stalled: 0
                    ranges_active: 0
                    oldest_lag_seconds: 0
                    newest_ledger: 62637704
                  backfill_coverage:
                  - source: aquarius
                    applies: true
                    entries: 1
                    coverage_pct: 1
                    completeness_pct: 1
                    completeness_complete: true
                    completeness_lake_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
                  market_cap:
                    entries_count: 187
                    oldest_fetched_at: '2026-07-03T21:38:00Z'
                    newest_fetched_at: '2026-07-03T22:38:00Z'
                  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
                    backfill_safe: true
                    trade_count_24h: 12946
                    markets_count_24h: 142
                    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." }
                                completeness_pct:        { type: number,  description: "ADR-0033 watermark coverage: (watermark - genesis + 1) / (tip - genesis + 1). No sparsity threshold — a single PROVEN gap pins it. Absent until compute-completeness has run for the source." }
                                completeness_watermark:  { type: integer, format: int64, description: "Highest fully-verified ledger." }
                                completeness_complete:   { type: boolean, description: "SERVED/combined axis of the ADR-0033/ADR-0034 two-axis verdict: substrate ∧ recognition ∧ the retention-scoped projection reconcile." }
                                completeness_lake_complete: { type: boolean, description: "LAKE (archive) axis: substrate ∧ recognition only, genesis-to-tip. A source is routinely lake_complete=true with complete=false — the archive is proven genesis-complete while the served tier is still reconciling." }
                                completeness_computed_at: { type: string, format: date-time, description: "When compute-completeness last ran for this source." }
                          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" }

  /diagnostics/archive:
    get:
      operationId: getDiagnosticArchive
      x-stability: experimental
      tags: [meta]
      summary: Latest archive-completeness report (ADR-0017).
      description: |
        Read-through of the JSON report the archive-completeness
        daemon (`stellarindex-ops archive-completeness verify`, daily
        systemd timer) writes after its check → fix → re-check cycle:
        the ledger range scanned, and per-archive expected / found /
        missing counts. `scanned_at` is the daemon's own timestamp —
        a stale value means the timer stopped firing, which is itself
        a signal (the API deliberately does not re-synthesize
        freshness).

        The `primary` section is currently always absent — the shipped
        daemon enforces only the cross-anchor archive; the primary
        (galexie-archive) scan is monitored through the completeness
        verdicts on `/v1/coverage` instead.

        Degradation: 503 when the deployment has no
        `api.archive_report_path` configured; 404 while the configured
        file doesn't exist yet (fresh host — the daemon hasn't
        produced a report).
      responses:
        "200":
          description: The latest report, verbatim from the daemon.
          content:
            application/json:
              example:
                data:
                  schema: "1"
                  scanned_at: '2026-07-03T04:00:00Z'
                  range: { from: 2, to: 63305532 }
                  cross_anchor:
                    archive_root: /srv/history-archive
                    expected: 989148
                    found: 989148
                    missing_count: 0
                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
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [schema, scanned_at, range]
                        properties:
                          schema: { type: string, example: "1" }
                          scanned_at: { type: string, format: date-time }
                          range:
                            type: object
                            required: [from, to]
                            properties:
                              from: { type: integer, format: int64 }
                              to: { type: integer, format: int64 }
                          cross_anchor:
                            type: object
                            required: [archive_root, expected, found, missing_count]
                            properties:
                              archive_root: { type: string, example: /srv/history-archive }
                              expected: { type: integer }
                              found: { type: integer }
                              missing_count: { type: integer }
                              missing:
                                type: array
                                items: { type: integer, format: int64 }
                              truncated:
                                type: boolean
                                description: |
                                  True when the daemon capped the `missing`
                                  list; `missing_count` stays accurate.
                          primary:
                            type: object
                            required: [bucket_name, expected, found, missing_count]
                            properties:
                              bucket_name: { type: string }
                              expected: { type: integer }
                              found: { type: integer }
                              missing_count: { type: integer }
                              missing_ranges:
                                type: array
                                items:
                                  type: object
                                  required: [start, end]
                                  properties:
                                    start: { type: integer, format: int64 }
                                    end: { type: integer, format: int64 }
        "404":
          description: |
            The configured report file doesn't exist yet — the daemon
            hasn't completed a run on this host.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503":
          description: No archive_report_path configured on this deployment.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /incidents:
    get:
      operationId: listIncidents
      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, "null"]
                                  format: date-time
                                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:
      operationId: getIncidentsAtomFeed
      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:
      operationId: getCoverage
      tags: [meta]
      summary: Per-source completeness verdicts (ADR-0033/ADR-0034) — 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`.

        **Two axes** (ADR-0033/ADR-0034 two-axis verdict): `lake_complete`
        is the certified ClickHouse ARCHIVE's genesis-to-tip claim
        (substrate ∧ recognition only) — "everything for every ledger,
        ever" for that source. `complete` is the SERVED tier's claim:
        `lake_complete` additionally gated by the projection reconcile,
        which is retention-scoped by design (Postgres is the served
        working set, not the archive — ADR-0034). A source can read
        `lake_complete: true, complete: false`: the archive is proven
        genesis-complete even though the served tier only reconciles
        within its retention window.
      responses:
        "200":
          description: Latest verdict per source, source-sorted.
          content:
            application/json:
              example:
                data:
                  sources:
                  - source: aquarius
                    complete: true
                    lake_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'
                  - source: soroswap
                    complete: false
                    lake_complete: true
                    substrate_ok: true
                    recognition_ok: true
                    projection_ok: false
                    genesis_ledger: 61500000
                    watermark_ledger: 63305532
                    tip_ledger: 63305532
                    coverage_pct: 1
                    detail: 'projection: 3 mismatched ledger(s) outside the served retention window'
                    computed_at: '2026-07-03T05:30:21.937134Z'
                  complete_sources: 14
                  lake_complete_sources: 15
                  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, lake_complete_sources, total_sources]
                        properties:
                          sources:
                            type: array
                            items:
                              type: object
                              required:
                                [source, complete, lake_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
                                  description: |
                                    SERVED/combined axis: substrate ∧ recognition ∧
                                    projection. Projection reconcile is
                                    retention-scoped (ADR-0034: Postgres is the
                                    served tier, not the archive), so this can be
                                    false even when lake_complete is true.
                                lake_complete:
                                  type: boolean
                                  description: |
                                    LAKE/archive axis: substrate ∧ recognition only,
                                    genesis-to-tip, decoupled from the
                                    retention-scoped projection reconcile — "the
                                    certified ClickHouse archive is contiguous +
                                    hash-chained + recognition-complete from genesis
                                    to tip for this source." Two-axis verdict per
                                    notes/DECISION-genesis-complete-verdict-2026-07-16.md
                                    (Option B).
                                substrate_ok: { type: boolean }
                                recognition_ok: { type: boolean }
                                projection_ok: { type: boolean }
                                genesis_ledger: { type: integer, format: int64 }
                                watermark_ledger:
                                  type: integer
                                  format: int64
                                  description: Lake-axis watermark (substrate ∧ recognition), NOT gated by projection.
                                tip_ledger: { type: integer, format: int64 }
                                coverage_pct:
                                  type: number
                                  minimum: 0
                                  maximum: 1
                                  description: |
                                    Lake-axis coverage (watermark vs tip) — see
                                    watermark_ledger. A FRACTION in [0,1] despite
                                    the `_pct` name: 1.0 means the verdict reaches
                                    the tip, not 100.
                                first_problem_ledger: { type: integer, format: int64 }
                                detail: { type: string }
                                computed_at: { type: string, format: date-time }
                          complete_sources:
                            type: integer
                            description: Count of sources with complete=true (served/combined axis).
                          lake_complete_sources:
                            type: integer
                            description: Count of sources with lake_complete=true (lake/archive axis).
                          total_sources: { type: integer }
        "503":
          description: No completeness reader wired on this deployment.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
  /protocols:
    get:
      operationId: listProtocols
      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).
        DEX/AMM protocols with an absolute reserve source additionally
        carry a `tvl` snapshot (see the `ProtocolRow.tvl` schema for
        its provenance + lower-bound semantics).

        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`.

        `contract_count` is served from a prewarmed, stale-while-
        revalidate cache (the roster is a `SELECT DISTINCT … LIMIT 5000`
        served-tier scan, and this route is unauthenticated, so scanning
        per protocol on every origin miss was a DoS surface). When a
        source's roster read FAILS and no last-good count is cached, the
        source is OMITTED from `protocols` and named in `coverage_note`
        rather than published with a fabricated `contract_count: 0` — a
        failed read is not a real zero.
      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 }
                          coverage_note:
                            type: string
                            description: |
                              Honest-degrade signal (mirrors the
                              `coverage_note` on `/accounts/{id}/movements`
                              and `/tx/{hash}`): present ONLY when one or
                              more sources were OMITTED from `protocols`
                              because their contract-roster read failed and
                              no cached count was available. Names the
                              omitted sources. A degraded source is dropped
                              (never shown with a fabricated
                              `contract_count: 0`), so a complete directory
                              carries no note.
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /protocols/{name}:
    get:
      operationId: getProtocol
      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 }
        - name: days
          in: query
          required: false
          description: |
            Trailing window (days) for the `bespoke` per-category analytics
            block (its KPIs, series, and tables). Whitelisted to 1, 7, 30, or
            90 (default 90) — each admitted window is a distinct cached scan,
            so arbitrary values are rejected with a 400 problem+json rather
            than clamped. At `days=1` the bridge flow series (cctp inbound /
            outbound, rozo settled volume) are bucketed HOURLY with
            `YYYY-MM-DD"T"HH24:00` point timestamps; longer windows are daily
            (`YYYY-MM-DD`). The lake-analytics fields (`activity_series`,
            `event_breakdown`, `events_total`, `activity_window_days`) always
            cover the fixed 90-day lookback regardless of this parameter.
          schema: { type: integer, enum: [1, 7, 30, 90], default: 90 }
      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
                    tokens:
                    - CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
                    - CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
                    token_symbols:
                    - XLM
                    - USDC
                    pair: XLM/USDC
                    events: 569
                    last_seen: '2026-07-03T21:40:33Z'
                  event_kinds:
                  - blend.position
                  - blend_backstop.event
                  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
                  analytics:
                    status: ok
                    as_of: '2026-07-03T22:38:12Z'
                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).

                                  Shared sub-shapes: `kpis` (label/value/
                                  unit/hint cards), `series` (named
                                  {date, value} time-series; values are
                                  numeric STRINGS — ADR-0003), `tables`
                                  (title/columns/rows), `notes` (caveat
                                  lines), and — since 1.15.0 —
                                  `breakdowns`: named composition datasets
                                  for donut/pie rendering
                                  ({title, unit, rows: [{label, value,
                                  count}]}, value-sorted descending;
                                  e.g. CCTP's "Inflows by source chain" /
                                  "Outflows by destination chain").
                              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). }
                                    tokens:
                                      type: array
                                      items: { type: string }
                                      description: "Ordered raw token contract C-strkeys the pool holds — 2 for a pair, 3/4 for an Aquarius stableswap, N for a Comet weighted pool, or the reserve-asset set for a lending market (blend). Parallel to token_symbols. Absent for non-pool contracts (factories, oracles)."
                                    token_symbols:
                                      type: array
                                      items: { type: string }
                                      description: "Human display symbols for `tokens`, same order (XLM, USDC, AQUA, …). An unresolvable token degrades to a short truncated contract (\"CAS3…OWMA\") so this stays parallel to `tokens`."
                                    pair:
                                      type: string
                                      description: "Human roster label — token_symbols joined with \"/\": \"XLM/USDC\" for a pair, \"XLM/USDC/USDT\" for a 3-token stableswap, or the reserve-asset list for a lending market. Absent when no tokens resolve."
                                      example: XLM/USDC
                                    kind: { type: string, enum: [factory, instance, module], description: "Role within the protocol — a verified trust-root (factory), a factory-deployed pool/vault/market (instance), or a folded-in sub-module contract that belongs to this protocol but emits on its own address (module — e.g. the Blend Backstop)." }
                                    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 }
                              analytics:
                                type: object
                                description: |
                                  Explicit health of the view's analytics halves
                                  (the lake-derived fields above and `bespoke`) —
                                  since 1.16.0. Distinguishes a degraded build
                                  from genuinely-empty data: `ok` means every
                                  analytics component built successfully (an
                                  absent `bespoke` under `ok` is a category with
                                  none — a real absence); `stale` means the view
                                  is real but served past its freshness horizon
                                  while a background rebuild runs (`as_of` says
                                  how old; `flags.stale` is set on the envelope
                                  too); `unavailable` means at least one
                                  component failed or was skipped — absent
                                  blocks and zero-valued analytics fields then
                                  mean DEGRADATION, not zero activity, and
                                  clients should render a hint rather than the
                                  values.
                                required: [status]
                                properties:
                                  status:
                                    type: string
                                    enum: [ok, stale, unavailable]
                                    description: Health of the analytics in this view.
                                  as_of:
                                    type: string
                                    format: date-time
                                    description: When this view's analytics were built.
        "404":
          description: Unknown protocol name.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
  /sdex/orderbook:
    get:
      operationId: getSdexOrderbook
      tags: [protocols]
      summary: SDEX order-book depth for one classic pair.
      description: |
        Live bid/ask depth for a classic (selling, buying) market,
        aggregated by exact price level from the LIVE `offer` ledger
        entries. `selling` is the market's BASE asset (what asks sell),
        `buying` the QUOTE; classic asset forms only (`native`,
        `CODE-G...`) — SDEX offers cannot reference Soroban assets.

        Source of truth is an IN-PROCESS live offer book maintained off
        the certified lake: loaded once at process start (the offer
        slice carries its assets only inside the entry XDR, so per-pair
        lake reads are not viable within the read budget) and advanced
        with incremental change reads every ~60s — `as_of_ledger` /
        `snapshot_at` state exactly how fresh the served book is. Until
        the initial load completes the endpoint returns a 503 problem
        (`orderbook-loading`), never a fabricated empty book.

        Prices are exact rationals: `price` renders the reduced
        fraction `price_r` at 7 decimal places (bid prices are the
        exact inverse of the stored offer price — no float math).
        Amounts are decimal strings in whole 7-decimal asset units;
        `ask_offers` / `bid_offers` count each side BEFORE the `depth`
        cap so truncation is visible. Served with `public, max-age=30`.
      parameters:
        - name: selling
          in: query
          required: true
          description: "Base asset (canonical classic id: `native` or `CODE-G...`)."
          schema: { type: string, example: native }
        - name: buying
          in: query
          required: true
          description: "Quote asset (canonical classic id)."
          schema: { type: string, example: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN }
        - name: depth
          in: query
          description: Maximum aggregated price levels per side.
          schema: { type: integer, minimum: 1, maximum: 200, default: 25 }
      responses:
        "200":
          description: Aggregated depth, asks ascending / bids descending by price.
          content:
            application/json:
              example:
                data:
                  selling: native
                  buying: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  as_of_ledger: 63412345
                  snapshot_at: "2026-07-29T12:34:56Z"
                  asks:
                    - price: "0.3921569"
                      price_r: { n: 20, d: 51 }
                      base_amount: "1250.0000000"
                      quote_amount: "490.1960784"
                      cum_base_amount: "1250.0000000"
                      cum_quote_amount: "490.1960784"
                      offers: 3
                  bids:
                    - price: "0.3910000"
                      price_r: { n: 391, d: 1000 }
                      base_amount: "800.0000000"
                      quote_amount: "312.8000000"
                      cum_base_amount: "800.0000000"
                      cum_quote_amount: "312.8000000"
                      offers: 1
                  ask_offers: 3
                  bid_offers: 1
                  depth: 25
                as_of: "2026-07-29T12:35:02.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: { $ref: "#/components/schemas/SDEXOrderBook" }
        "400":
          description: Invalid selling/buying asset or depth.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          description: Order book snapshot still loading (or the lake reader is not wired on this deployment).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /ledger/tip:
    get:
      operationId: getLedgerTip
      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:
      operationId: streamLedgerTip
      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 }
              example: |
                id: 0198a4214a900001
                event: ledger_update
                data: {"data":{"latest_ledger":63316183,"ingested_at":"2026-07-03T22:38:47.833071Z","lag_seconds":2},"as_of":"2026-07-03T22:38:50.194761237Z"}

                :keepalive

                id: 0198a4214a900002
                event: ledger_update
                data: {"data":{"latest_ledger":63316184,"ingested_at":"2026-07-03T22:38:52.611403Z","lag_seconds":2},"as_of":"2026-07-03T22:38:52.812009481Z"}

        "500":        { $ref: "#/components/responses/InternalError" }
        "503":        { $ref: "#/components/responses/ServiceUnavailable" }

  /network/stats:
    get:
      operationId: getNetworkStats
      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, "null"]
                            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:
      operationId: getNetworkThroughput
      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 — plus
        end-of-day chain state off each day's last ledger: the
        cumulative fee pool and total XLM (stroop strings — the
        values exceed 2^53) and the protocol version in force.
        `fee_pool` is cumulative; daily fee burn is the delta between
        consecutive COMPLETE days. 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: 14832
                    txs: 5210044
                    ops: 10981233
                    events: 7498112
                    fee_pool: '48231457220441231'
                    total_coins: '1054439020873472922'
                    protocol_version: 23
                  - day: '2026-06-26'
                    ledgers: 14791
                    txs: 5232968
                    ops: 11025463
                    events: 7512422
                    fee_pool: '48231989301127744'
                    total_coins: '1054439020873472922'
                    protocol_version: 23
                  - day: '2026-06-27'
                    ledgers: 8934
                    txs: 3160221
                    ops: 6655109
                    events: 4537880
                    fee_pool: '48232301518824410'
                    total_coins: '1054439020873472922'
                    protocol_version: 23
                    partial: true
                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 }
                            fee_pool:
                              type: string
                              description: >-
                                Cumulative network fee pool at the day's last
                                ledger, in stroops (decimal string — exceeds
                                2^53). Daily fee burn = the delta between
                                consecutive COMPLETE days.
                            total_coins:
                              type: string
                              description: >-
                                Total XLM in existence at the day's last
                                ledger, in stroops (decimal string — exceeds
                                2^53).
                            protocol_version:
                              type: integer
                              description: Protocol version in force at the day's last ledger.
                            partial:
                              type: boolean
                              description: >-
                                True when this bucket does not cover a whole UTC
                                day — in practice only today, which is still
                                accumulating. Render it distinctly and EXCLUDE it
                                from window totals; every other bucket is a
                                complete day (the window is day-aligned).
                                Omitted when false.
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /methodology:
    get:
      operationId: getMethodology
      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
                    on_chain: 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:
      operationId: listSources
      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" }

  /sources/{name}/health:
    get:
      operationId: getSourceHealth
      tags: [meta]
      summary: One source's live health row.
      description: |
        The same per-source health shape the `sources` section of
        `/v1/diagnostics/ingestion` carries — registry metadata
        (class, subclass, VWAP-eligibility, backfill-safety) joined
        with trailing-24h liveness counters — addressable per venue so
        a page tracking one source polls it cheaply instead of pulling
        the full operator snapshot.

        `entries_24h` counts every decoded event for the source
        (trades, oracle updates, supply observations, …) and is the
        liveness signal that works for non-trade sources;
        `trade_count_24h` / `volume_24h_usd` / `markets_count_24h` are
        trades-table aggregates and legitimately 0 for oracles, FX
        feeds, and bridges.

        Served from a 15-second background-refreshed snapshot;
        `Cache-Control` is `private, no-cache` accordingly. Unknown
        source names 404 (the registry is static per deploy — see
        `/v1/sources` for the catalogue).
      parameters:
        - name: name
          in: path
          required: true
          description: Source name as listed by /v1/sources.
          schema:
            type: string
            example: kraken
      responses:
        "200":
          description: The source's health row.
          content:
            application/json:
              example:
                data:
                  name: kraken
                  class: exchange
                  subclass: cex
                  include_in_vwap: true
                  backfill_safe: true
                  trade_count_24h: 10023
                  entries_24h: 10023
                  volume_24h_usd: "1245001.55"
                  markets_count_24h: 4
                as_of: '2026-07-03T22:38:53.6445723Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                allOf:
                  - { $ref: "#/components/schemas/EnvelopeMeta" }
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required:
                          [name, class, include_in_vwap, backfill_safe,
                           trade_count_24h, entries_24h, markets_count_24h]
                        properties:
                          name: { type: string, example: kraken }
                          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 }
                          entries_24h: { type: integer, format: int64 }
                          volume_24h_usd: { type: string }
                          markets_count_24h: { type: integer, format: int64 }
        "404":
          description: No registered source with that name.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /aggregators:
    get:
      operationId: listAggregators
      tags: [meta]
      summary: Router / aggregator-vault registry with routed-via 24h rollup.
      description: |
        Lists the routers registry — entry contracts that route
        per-tx into underlying venues (Soroswap router, plus any
        aggregator contract OBSERVED WRAPPING it — see below) and
        aggregator vaults that hold persistent capital (DeFindex) —
        each with its routed-via attribution over the trailing 24
        hours: how many `trades` rows (and how much USD volume)
        arrived at the underlying DEX via that router or wrapper.
        Attribution is stamped on trades as `routed_via` (also
        visible per-row on `/v1/history`) by joining same-transaction
        router invocations; the tag is first-wins and applies to
        `kind=router` entries only — vault entries always report
        zero routed trades (their capital state lives on the
        protocol surfaces, not per-tx flow).

        A router call observed as a SUB-INVOCATION (some other
        contract called the router as part of its own authorized
        call tree — the common real-world shape, per
        `docs/architecture/contract-call-coverage-audit.md`) is
        attributed to the OUTERMOST wrapping contract when that
        contract is itself a registered `router`-kind entry, instead
        of being lumped into the plain router's count. See each
        row's `notes` for the honest-degrade caveats this implies —
        `auto_discovered: true` rows are evidence-observed, not
        vendor-verified, and their count only reflects router calls
        recorded with call-path data (live since 2026-07-10); a full
        historical back-tag is a queued r1 re-derive.

        The window is a rolling observation recomputed per request
        (like `/v1/network/stats`), not a closed-bucket series.
      responses:
        "200":
          description: Array of registry entries, vaults after routers, sorted by name.
          content:
            application/json:
              example:
                data:
                - contract_id: CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH
                  name: soroswap-router
                  kind: router
                  protocol: soroswap
                  auto_discovered: false
                  routed_trades_24h: 342
                  routed_volume_24h_usd: '18211.4052710000000000'
                  last_routed_at: '2026-07-04T21:58:11Z'
                  notes:
                  - routed_trades_24h combines direct calls to this router, calls
                    wrapped by an aggregator this registry doesn't recognise, and
                    legacy rows recorded before call-path tracking (2026-07-10) —
                    those three cases can't be told apart yet.
                - contract_id: CD45PQFHSIUMIC4MVZXCQ2RD6REKXJMEHWRN56TWT3C4DV2U4DHVJRZH
                  name: soroswap-router-aggregator-exec
                  kind: router
                  protocol: unattributed
                  auto_discovered: true
                  routed_trades_24h: 11
                  routed_volume_24h_usd: '640.1100000000000000'
                  last_routed_at: '2026-07-10T09:12:47Z'
                  notes:
                  - Evidence-observed contract, not vendor- or WASM-audit-verified
                    (see the registry seed migration's notes).
                  - routed_trades_24h only counts router calls recorded with call_path
                    data (live since 2026-07-10); earlier activity through this wrapper
                    is not yet attributed pending a queued historical re-derive — a low
                    or zero count does not mean this router carried little volume.
                - contract_id: CDB2WMKQQNVZMEBY7Q7GZ5C7E7IAFSNMZ7GGVD6WKTCEWK7XOIAVZSAP
                  name: defindex-vault-usdc-autocompound
                  kind: aggregator-vault
                  protocol: defindex
                  auto_discovered: false
                  routed_trades_24h: 0
                  routed_volume_24h_usd: null
                  last_routed_at: null
                as_of: '2026-07-04T22:38:53.6445723Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/AggregatorsEnvelope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /sac-wrappers:
    get:
      operationId: listSacWrappers
      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:
      operationId: listPairs
      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:
      operationId: getOracleLastPrice
      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:
      operationId: getOraclePrices
      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:
      operationId: getOracleCrossPrice
      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:
      operationId: getAccountMe
      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:
      operationId: getAccountUsage
      tags: [account]
      summary: Daily per-endpoint request counters for the authenticated key.
      description: |
        Returns per-day, per-endpoint-family usage rows for the
        authenticated caller over the trailing 30 days: one row per
        (date, endpoint) with `requests` (allowed traffic),
        `errors` (4xx excluding 429, plus 5xx), and `throttled`
        (429 rejections — tallied separately and never counted
        against monthly quota). `endpoint` is the route PATTERN
        (e.g. `/v1/assets/{asset_id}`), never a raw URL.
        Sum `requests` grouped by `date` for daily totals.

        Throttling attribution: a 429 is rejected BEFORE the router
        resolves a route, so throttled counts are reported under the
        `unmatched` endpoint rather than the route the caller was
        aiming at. Sum `throttled` across endpoints for a caller's
        true throttled total; do not read a per-route `throttled` of
        zero as "this route was never throttled".

        Pipeline: the usage middleware counts every authenticated
        request per (subject, route pattern, outcome class) in
        Redis; the API's rollup worker folds those counters into
        the `usage_daily` Timescale table every 5 minutes, which
        this endpoint reads. Rows can therefore trail live traffic
        by up to ~5 minutes.

        Fallback shape: deployments where the rollup table has no
        rows yet (or Postgres is absent) degrade to the legacy
        per-day rows — one row per date with `endpoint` omitted
        and `errors`/`throttled` zero. Redis-less deployments
        return an empty array; the absence of the backend 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'
                  endpoint: /v1/price
                  requests: 18234
                  errors: 12
                  throttled: 0
                - date: '2026-07-02'
                  endpoint: /v1/price
                  requests: 20117
                  errors: 3
                  throttled: 0
                # 429s land under `unmatched`: the throttle rejects
                # before the router resolves a route pattern.
                - date: '2026-07-02'
                  endpoint: unmatched
                  requests: 0
                  errors: 0
                  throttled: 41
                - date: '2026-07-02'
                  endpoint: /v1/assets/{asset_id}
                  requests: 512
                  errors: 0
                  throttled: 0
                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:
      operationId: listAccountKeys
      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") or verify rotation worked.
      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:
      operationId: createAccountKey
      tags: [account]
      summary: Create a new API key.
      description: |
        Issues a new API key for the authenticated caller and returns
        the plaintext exactly once.

        `scopes` (optional) confines the new key to route families:
        `read` (public data surfaces), `account` (`/v1/account/*`),
        `dashboard` (`/v1/dashboard/*`), `admin` (`/v1/admin/*`).
        Omitted/empty mints a full-access key — the posture every key
        minted before scopes shipped keeps. Scopes only narrow; they
        never grant anything the key's tier wouldn't already reach.

        One caller identifier may hold a bounded number of un-revoked
        keys (25 by default, operator-tunable). A mint that would cross
        the ceiling returns 409 — revoke a key via
        `DELETE /v1/account/keys/{keyID}` and retry.
      security:
        - APIKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 128 }
                scopes:
                  type: array
                  items:
                    type: string
                    enum: [read, account, dashboard, admin]
                  description: |
                    Optional capability scopes. Empty = full access
                    (back-compat). Unknown values 400.
              required: [label]
      responses:
        "201":
          description: New key — plaintext shown **once**.
          content:
            application/json:
              example:
                data:
                  key_id: kid_7f3a2c1b9e7d4f6a
                  plaintext: sip_7f3a2c1b9e7d4f6a8b3c2d1e9f8a7b6c5d4e3f2c1b9a8f7e6d5c4b3a2f1e9d8c
                  key_prefix: sip_7f3a2c1b
                  label: production-api-1
                  scopes:
                  - read
                as_of: '2026-07-03T22:44:12.611403Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/KeyCreatedEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: |
            Active-key quota reached for this caller identifier. Revoke
            an existing key and retry.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /account/keys/{keyID}:
    delete:
      operationId: deleteAccountKey
      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.

  /account/admin/lookup:
    get:
      operationId: adminLookupCustomer
      tags: [admin]
      summary: Staff — customer look-up by user email or account slug.
      description: |
        Staff "Customer look-up" tool (platform-spec §6): resolve an
        account by one of its users' email address OR by account slug,
        and return the account's tier/status plus the users on it.
        Read-only.

        Auth is a dashboard SESSION cookie, NOT an API key: the route is
        wrapped in `RequireSession` (401 without a session) and the
        handler additionally gates on the session user's staff flag — a
        logged-in non-staff customer gets 403, never another customer's
        data. (Note the deliberate mismatch with the sibling
        `/v1/admin/*` operator endpoints, which are `APIKeyAuth`
        operator-tier; this staff tool lives behind the dashboard session
        instead.) Every successful look-up lands a `staff.customer.lookup`
        audit row.

        Supply EXACTLY ONE of `email` or `slug`. Supplying neither is a
        400 (`provide ?email= or ?slug=`). The response carries
        `Cache-Control: no-store` because the body is staff-only customer
        PII.
      security:
        - SessionCookie: []
      parameters:
        - name: email
          in: query
          required: false
          schema: { type: string, format: email }
          description: |
            Resolve the account via one of its users' email address
            (case-insensitive). Mutually exclusive with `slug` — supply
            exactly one.
        - name: slug
          in: query
          required: false
          schema: { type: string }
          description: |
            Resolve the account by its slug (case-insensitive). Mutually
            exclusive with `email` — supply exactly one.
      responses:
        "200":
          description: |
            Matched customer. Served with `Cache-Control: no-store`
            (staff-only PII).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminLookupResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Authenticated session user is not staff.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /admin/keys:
    post:
      operationId: createAdminKey
      tags: [admin]
      summary: Operator — mint an API key for another identifier.
      description: |
        The operator key-mint path the self-service POST
        /v1/account/keys has always pointed at: unlike self-service
        (which inherits the caller's identifier + tier), the
        operator names the TARGET `identifier` and may pin `tier`
        (`apikey` default, or `operator`), `rate_limit_per_min`,
        and capability `scopes` explicitly.

        Restricted to operator-tier credentials (staff-issued via
        stellarindex-ops; never granted to public callers) — other
        tiers 403. Requires an `X-Reason` header captured into the
        audit log, the same contract as `PATCH /v1/admin/accounts/{id}`
        and `DELETE /v1/admin/keys/{keyID}`; a request without it 400s.
        Every successful mint is audit-logged: a structured log line
        unconditionally plus a persisted `audit_log` row (`key.mint`,
        staff actor, carrying the reason) when the deployment wires the
        audit store.
      security:
        - APIKeyAuth: []
      parameters:
        - name: X-Reason
          in: header
          required: true
          schema: { type: string }
          description: Free-form reason captured into the audit log.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                identifier:
                  type: string
                  description: |
                    Owner reference the minted key authenticates as
                    (e.g. `acct:<slug>`).
                label: { type: string, maxLength: 128 }
                tier:
                  type: string
                  enum: [apikey, operator]
                  default: apikey
                rate_limit_per_min:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: 0 inherits the deployment default.
                scopes:
                  type: array
                  items:
                    type: string
                    enum: [read, account, dashboard, admin]
              required: [identifier, label]
      responses:
        "201":
          description: New key — plaintext shown **once**.
          content:
            application/json:
              example:
                data:
                  key_id: kid_2c9e6f4a1b8d3c7e
                  plaintext: sip_2c9e6f4a1b8d3c7e5a9f8b2d1c6e4f7a3b9c8d2e1f6a5b4c3d2e1f9a8b7c6d5e
                  key_prefix: sip_2c9e6f4a
                  label: partner-integration-key
                  scopes:
                  - read
                  - account
                as_of: '2026-07-03T22:44:30.117209Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/KeyCreatedEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /admin/keys/{keyID}:
    delete:
      operationId: deleteAdminKey
      tags: [admin]
      summary: Operator — revoke another identifier's API key (kill switch).
      description: |
        Revokes a leaked or abused credential belonging to any customer.
        The self-service `DELETE /v1/account/keys/{keyID}` is scoped to
        the caller's OWN identifier, so it requires the compromised
        customer's credential; this is the operator path.

        `identifier` is required rather than inferred: the revoke is
        scoped to a named owner so a mistyped key id cannot reach a
        different customer's account. Read both values off
        `GET /v1/account/keys` or the mint's audit row.

        Restricted to operator-tier credentials — other tiers 403.
        Requires an `X-Reason` header captured into the audit log; every
        successful revoke lands a `key.revoke` audit row (staff actor).

        204 covers both "revoked" and "no such key for that identifier":
        the two are deliberately indistinguishable so the endpoint can't
        be used to enumerate key ids across accounts. To disable a whole
        account instead, PATCH its `status` on
        `/v1/admin/accounts/{id}`.
      security:
        - APIKeyAuth: []
      parameters:
        - name: keyID
          in: path
          required: true
          schema: { type: string }
          description: "Public-safe `kid_<hex>` identifier of the key to revoke."
        - name: identifier
          in: query
          required: true
          schema: { type: string }
          description: "Owner reference the key belongs to (e.g. `acct:<slug>` or `signup-<hash>`)."
        - name: X-Reason
          in: header
          required: true
          schema: { type: string }
          description: Free-form reason captured into the audit log.
      responses:
        "204":
          description: Revoked (or no-op when no such key exists for that identifier).
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /admin/accounts/{id}:
    get:
      operationId: getAdminAccount
      tags: [admin]
      summary: Operator — read an account's tier + overrides.
      description: |
        Returns the account-level tier plus the per-account
        `rate_limit_per_min_override` / `monthly_request_quota_override`
        (0 = inherit tier default). Operator-tier only; read-only (not
        audit-logged — the audit log records mutations, not reads).
      security:
        - APIKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: Platform account UUID.
      responses:
        "200":
          description: Account view.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminAccountEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    patch:
      operationId: updateAdminAccount
      tags: [admin]
      summary: Operator — set an account's tier, status, and rate-limit / quota overrides.
      description: |
        Sets the account plan tier, lifecycle `status`, and/or the
        per-account `rate_limit_per_min_override` /
        `monthly_request_quota_override` (admin Phase 1.5). All fields are
        optional; at least one must be present. An override value of `0`
        clears it (inherit the tier default). The rate-limit override acts
        as an account-wide floor applied by the Postgres API-key validator
        on the next Lookup — it raises keys budgeted below it, never
        lowers a key budgeted above.

        `status` is the account-level KILL SWITCH. Moving an account to
        `suspended` or `closed` stops its API keys authenticating and
        denies its dashboard sessions; `suspended_reason` is stored
        alongside and `suspended_at` is stamped on the first transition
        away from `active`. Moving back to `active` clears both. To kill a
        single leaked credential instead, use
        `DELETE /v1/admin/keys/{keyID}`.

        Operator-tier only (staff-issued via stellarindex-ops; never
        granted to public callers) — other tiers 403. Requires an
        `X-Reason` header captured into the audit log; every successful
        mutation lands an `account.override.set` audit row (staff actor)
        when the deployment wires the audit store.
      security:
        - APIKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: Platform account UUID.
        - name: X-Reason
          in: header
          required: true
          schema: { type: string }
          description: Free-form reason captured into the audit log.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tier:
                  type: string
                  enum: [free, partner, starter, pro, business, enterprise]
                  description: |
                    Canonical model is `free` / `partner` (the platform
                    is free; partner = staff-set per-account limits).
                    Legacy paid-tier names are accepted and mapped in
                    code: starter→free; pro/business/enterprise→partner.
                status:
                  type: string
                  enum: [active, suspended, closed]
                  description: |
                    Account lifecycle state — the kill switch. `suspended`
                    / `closed` stop the account's keys authenticating and
                    deny its dashboard sessions.
                suspended_reason:
                  type: string
                  maxLength: 500
                  description: |
                    Stored alongside a suspension. Ignored when `status`
                    is absent; cleared on a move back to `active`.
                    Distinct from the mandatory `X-Reason` header, which
                    records why the OPERATOR acted.
                rate_limit_per_min_override:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: 0 clears the override (inherit tier default).
                monthly_request_quota_override:
                  type: integer
                  format: int64
                  minimum: 0
                  description: 0 clears the override (inherit tier default).
      responses:
        "200":
          description: Updated account view.
          content:
            application/json:
              example:
                data:
                  id: 3d8f1a6c-9e2b-4c7d-8a5f-1b6e3d9c2a4f
                  name: Acme Trading Desk
                  slug: acme-trading-desk
                  tier: partner
                  status: active
                  billing_email: ops@acme.example.com
                  created_at: '2026-04-12T09:15:00Z'
                  rate_limit_per_min_override: 60000
                  monthly_request_quota_override: 50000000
                as_of: '2026-07-03T22:44:41.902317Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/AdminAccountEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /admin/status-notices:
    get:
      operationId: listAdminStatusNotices
      tags: [admin]
      summary: Operator — list all status notices (active + resolved).
      description: |
        The operator history view of the customer-facing status banners
        (admin Phase 1.5 incident tooling), newest first. Operator-tier
        only; read-only.
      security:
        - APIKeyAuth: []
      responses:
        "200":
          description: Notice list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusNoticesEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createAdminStatusNotice
      tags: [admin]
      summary: Operator — post a customer-facing status banner.
      description: |
        Creates a LIVE, human-authored status banner shown on the public
        status surface via GET /v1/status/notices (platform-spec §7.1
        "Trigger maintenance mode banner"). Distinct from the embedded
        post-mortem corpus (GET /v1/incidents) and the Alertmanager-
        derived incidents block on GET /v1/status.

        Operator-tier only. Requires an `X-Reason` header captured into
        the audit log; audit-logged as `status_notice.create` (staff
        actor).
      security:
        - APIKeyAuth: []
      parameters:
        - name: X-Reason
          in: header
          required: true
          schema: { type: string }
          description: Free-form reason captured into the audit log.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, minLength: 1, maxLength: 200 }
                body: { type: string, minLength: 1, maxLength: 5000 }
                severity:
                  type: string
                  enum: [maintenance, minor, major, critical]
              required: [title, body, severity]
      responses:
        "201":
          description: Created notice.
          content:
            application/json:
              example:
                data:
                  id: 6a1d9e3c-7f2b-4a8d-9c5e-3b1f6a2d8e4c
                  title: Delayed price freshness on r1
                  body: >-
                    We're investigating elevated /v1/price latency on r1
                    (EU). No data loss; freshness is degraded up to ~2
                    minutes. Updates here as we learn more.
                  severity: minor
                  status: active
                  created_at: '2026-07-03T22:45:02.004811Z'
                  updated_at: '2026-07-03T22:45:02.004811Z'
                as_of: '2026-07-03T22:45:02.004811Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/StatusNoticeEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /admin/status-notices/{id}/resolve:
    post:
      operationId: resolveAdminStatusNotice
      tags: [admin]
      summary: Operator — resolve (clear) a status banner.
      description: |
        Flips an active status banner to resolved — it drops off the
        public GET /v1/status/notices surface but is retained for the
        operator history + audit trail. Idempotent. Operator-tier only;
        requires an `X-Reason` header. Audit-logged as
        `status_notice.resolve` (staff actor).
      security:
        - APIKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: Status notice UUID.
        - name: X-Reason
          in: header
          required: true
          schema: { type: string }
          description: Free-form reason captured into the audit log.
      responses:
        "200":
          description: Resolved notice.
          content:
            application/json:
              example:
                data:
                  id: 6a1d9e3c-7f2b-4a8d-9c5e-3b1f6a2d8e4c
                  title: Delayed price freshness on r1
                  body: >-
                    We're investigating elevated /v1/price latency on r1
                    (EU). No data loss; freshness is degraded up to ~2
                    minutes. Updates here as we learn more.
                  severity: minor
                  status: resolved
                  created_at: '2026-07-03T22:45:02.004811Z'
                  updated_at: '2026-07-03T23:12:47.331098Z'
                  resolved_at: '2026-07-03T23:12:47.331098Z'
                as_of: '2026-07-03T23:12:47.331098Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema: { $ref: "#/components/schemas/StatusNoticeEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not operator-tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /register:
    post:
      operationId: registerApiKey
      tags: [account]
      summary: Open registration — create a free account + first API key in one call.
      description: |
        Public, anonymous-tier, and deliberately friction-free — the
        canonical **agent onboarding** path. One POST (an empty body
        is fine) creates a free-tier platform account and mints its
        first API key:

            curl -X POST https://api.stellarindex.io/v1/register

        `name` and `email` are both optional. The email is
        contact-only: no verification email is sent, nothing is keyed
        on it, and duplicates are allowed. The response carries the
        plaintext key **once** plus the account's free-tier limits so
        a client can self-configure without a second request. Staff
        can later raise a specific account's limits (the partner
        path) via the operator override endpoints.

        Abuse posture: rides the same per-IP signup throttle as
        `POST /v1/signup` (shared budget, default 5/hour/IP → 429),
        underneath the global anonymous rate limit. When a
        Content-Type header is present it must be
        `application/json`.

        Already-authenticated callers receive 400 — they should mint
        additional keys via POST /v1/account/keys instead.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:  { type: string, maxLength: 128, description: "Display name for the account (defaults to \"api-registered account\")." }
                email: { type: string, format: email, description: "Optional contact address. Never verified, never required." }
            example:
              name: "my-trading-agent"
              email: "ops@example.com"
      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:
                          account_id: { type: string, format: uuid }
                          api_key:    { type: string, description: "Bearer token (`sip_<64hex>`). Shown ONCE; register again if lost." }
                          key_id:     { type: string }
                          key_prefix: { type: string }
                          tier:       { type: string, enum: [free] }
                          limits:
                            type: object
                            properties:
                              rate_limit_per_min: { type: integer }
                              monthly_quota:      { type: integer, format: int64 }
                              max_active_keys:    { type: integer }
                              max_webhooks:       { type: integer }
                              max_price_alerts:   { type: integer }
                            required: [rate_limit_per_min, monthly_quota, max_active_keys, max_webhooks, max_price_alerts]
                        required: [account_id, api_key, key_id, key_prefix, tier, limits]
              example:
                data:
                  account_id: "7d9f2a54-4f0e-4c1a-9b3d-2f6c8e1a0b5c"
                  api_key: "sip_4f9c1d8b3a7e2f1c9d4b8a6e3f2c1d9b8a7e6f5d4c3b2a1f4f9c1d8b3a7e2f1c"
                  key_id: "kid_8f3a2c1b9e7d4f6a8f3a2c1b"
                  key_prefix: "sip_4f9c1d8b"
                  tier: "free"
                  limits:
                    rate_limit_per_min: 1000
                    monthly_quota: 1000000
                    max_active_keys: 25
                    max_webhooks: 10
                    max_price_alerts: 25
                as_of: "2026-08-11T14:35:42.881Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false }
        "400":
          description: Invalid email / name too long / malformed body / already authenticated.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "415":
          description: Content-Type present but not application/json.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429":
          description: Per-IP registration throttle exhausted (shared with /v1/signup).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503":
          description: Platform account store not configured (Postgres unavailable).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /signup:
    post:
      operationId: signup
      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 free tier (1000 req/min). Prefer `POST /v1/register` for
        machine onboarding — it creates a real platform account and
        doesn't require an email. Idempotent on the email:
        a second call for the same email returns 409 with a pointer
        to the existing key (recover access via support, or rotate
        via /v1/account/keys once authenticated).

        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:
      operationId: verifySignup
      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:
      operationId: listDashboardKeys
      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:
      operationId: createDashboardKey
      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.
        Active-key quota is tier-aware (free 25, partner 250 —
        deployment-overridable); exceeding it returns 409.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateKeyRequest" }
      responses:
        "201":
          description: Key minted.
          content:
            application/json:
              example:
                plaintext: sip_9d4c8b2a6f1e3d7c5a8b9e2f4d1c6a3b8e5f7d2c9a1b4e6f3d8c5a2b9e7f1d4c
                key:
                  id: 7d9f2a54-4f0e-4c1a-9b3d-2f6c8e1a0b5c
                  name: production
                  description: main backend key
                  key_prefix: sip_9d4c8b2a
                  tier: apikey
                  rate_limit_per_min: 300
                  monthly_quota: 1000000
                  usage_alert_threshold_pct: 80
                  created_at: '2026-07-03T22:45:31.006204Z'
              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, OR the write was blocked as cross-site:
            state-changing dashboard requests must carry an `Origin`
            (or `Referer`) matching this API or an operator-allow-listed
            site (`cross-site-request-blocked`).
          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:
      operationId: deleteDashboardKey
      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, OR the write was blocked as cross-site:
            state-changing dashboard requests must carry an `Origin`
            (or `Referer`) matching this API or an operator-allow-listed
            site (`cross-site-request-blocked`).
          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:
      operationId: listDashboardWebhooks
      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:
      operationId: createDashboardWebhook
      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. Webhook quota is tier-aware (free 10,
        partner 100 — deployment-overridable); exceeding it
        returns 409.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookRequest" }
      responses:
        "201":
          description: Webhook registered.
          content:
            application/json:
              example:
                webhook:
                  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-07-03T22:45:47.220981Z'
                  updated_at: '2026-07-03T22:45:47.220981Z'
                secret: wsec_3f8a1c6d9e2b4f7a5c8d1e6b9f2a4c7d3e8b1f6a9c2d4e7f5a8b3c1d6e9f2a4c
              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, OR the write was blocked as cross-site:
            state-changing dashboard requests must carry an `Origin`
            (or `Referer`) matching this API or an operator-allow-listed
            site (`cross-site-request-blocked`).
          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:
      operationId: updateDashboardWebhook
      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:
              example:
                id: 0b6a3f2e-9c1d-4e7a-8f5b-6d2c4a1e9b0f
                name: ops-alerts-critical-only
                url: https://ops.example.com/hooks/stellarindex
                events:
                - incident.sev1
                enabled: true
                created_at: '2026-06-20T10:00:00Z'
                updated_at: '2026-07-03T22:46:03.558112Z'
              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" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Webhook not found.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    delete:
      operationId: deleteDashboardWebhook
      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" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/webhooks/{id}/deliveries:
    get:
      operationId: getDashboardWebhookDeliveries
      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" }

  /dashboard/price-alerts:
    get:
      operationId: listDashboardPriceAlerts
      tags: [dashboard]
      summary: Customer dashboard — list this account's price alerts.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Returns every price-threshold alert this
        account has registered, newest first. BACKLOG #60.
      responses:
        "200":
          description: Price-alert list.
          content:
            application/json:
              example:
                alerts:
                - id: 2f1c8e0a-9b3d-4e7a-8f5b-6d2c4a1e9b0f
                  base_asset: native
                  quote_asset: fiat:USD
                  condition: above
                  threshold: "0.15"
                  cooldown_seconds: 300
                  enabled: true
                  created_at: '2026-07-05T10:00:00Z'
                  updated_at: '2026-07-05T10:00:00Z'
              schema:
                type: object
                properties:
                  alerts:
                    type: array
                    items: { $ref: "#/components/schemas/DashboardPriceAlert" }
                required: [alerts]
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    post:
      operationId: createDashboardPriceAlert
      tags: [dashboard]
      summary: Customer dashboard — register a new price alert.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Registers a "notify me when <base>/<quote>
        goes <above|below> <threshold>" rule. When the aggregator's
        evaluator is enabled it enqueues a `price.alert` webhook to
        this account's subscribed webhooks (register one via
        POST /v1/dashboard/webhooks with `price.alert` in `events`).
        Owner / admin / member roles can register; viewer + billing
        403. Alert quota is tier-aware (free 25, partner 1000 —
        deployment-overridable); exceeding it returns 409.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePriceAlertRequest" }
      responses:
        "201":
          description: Price alert registered.
          content:
            application/json:
              example:
                id: 2f1c8e0a-9b3d-4e7a-8f5b-6d2c4a1e9b0f
                base_asset: native
                quote_asset: fiat:USD
                condition: above
                threshold: '0.15'
                cooldown_seconds: 300
                enabled: true
                created_at: '2026-07-03T22:46:14.882091Z'
                updated_at: '2026-07-03T22:46:14.882091Z'
              schema: { $ref: "#/components/schemas/DashboardPriceAlert" }
        "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 price alerts, OR the write was blocked as cross-site:
            state-changing dashboard requests must carry an `Origin`
            (or `Referer`) matching this API or an operator-allow-listed
            site (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "409":
          description: Account at the price-alert quota for its tier.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /dashboard/price-alerts/{id}:
    patch:
      operationId: updateDashboardPriceAlert
      tags: [dashboard]
      summary: Customer dashboard — update a price alert.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Patches any subset of base_asset / quote_asset
        / condition / threshold / cooldown_seconds / enabled. Omitted
        fields keep their current value. 404 when the alert doesn't
        exist OR belongs to a different account (same shape so
        attackers can't enumerate cross-account ids).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdatePriceAlertRequest" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              example:
                id: 2f1c8e0a-9b3d-4e7a-8f5b-6d2c4a1e9b0f
                base_asset: native
                quote_asset: fiat:USD
                condition: above
                threshold: '0.18'
                cooldown_seconds: 300
                enabled: true
                last_fired_at: '2026-07-02T16:04:11Z'
                created_at: '2026-07-03T22:46:14.882091Z'
                updated_at: '2026-07-03T22:47:02.114873Z'
              schema: { $ref: "#/components/schemas/DashboardPriceAlert" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: No valid session cookie.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Price alert not found.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
    delete:
      operationId: deleteDashboardPriceAlert
      tags: [dashboard]
      summary: Customer dashboard — delete a price alert.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Removes the alert. Idempotent from the
        customer's view — a cross-account or absent id returns 404.
      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" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "404":
          description: Price alert not found.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /auth/login:
    post:
      operationId: requestMagicLink
      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.

        Sets a short-lived, HttpOnly `stellarindex_login_intent`
        cookie that binds the emailed link to THIS browser;
        `/auth/callback` refuses to mint a session without it, so a
        link mailed on to a third party cannot sign that third party
        into this account. Up to three concurrently-live links per
        browser are remembered, so re-requesting doesn't invalidate
        the first mail. The cookie is cleared on a successful sign-in.

        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:
              example:
                status: sent
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [sent]
                required: [status]
        "400": { $ref: "#/components/responses/BadRequest" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/callback:
    get:
      operationId: consumeMagicLink
      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" }
        "403":
          description: |
            The link was opened in a browser that did not request it.
            `POST /auth/login` sets a short-lived, HttpOnly
            `stellarindex_login_intent` cookie binding the link to the
            requesting browser; without a matching witness no session
            is minted (login-CSRF defence). The token is NOT consumed,
            so the originating browser can still complete the sign-in.
            Clients should fall back to the 6-digit code, or request a
            fresh link from the device in hand.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "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:
      operationId: verifyLoginCode
      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:
              example:
                status: ok
              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" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/logout:
    post:
      operationId: logout
      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.
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/passkey/begin-login:
    post:
      operationId: beginPasskeyLogin
      tags: [auth]
      summary: Customer dashboard — begin a passkey (WebAuthn) sign-in.
      description: |
        Returns the WebAuthn credential-request options the browser
        passes to `navigator.credentials.get()`. Usernameless
        (discoverable credential): no email is asked for, so the
        endpoint reveals nothing to an anonymous caller.

        `userVerification` is `required`: the authenticator must
        verify the user (biometric / PIN) for the assertion to be
        accepted, so a passkey sign-in is never possession-only.

        Sets an HMAC-signed, HttpOnly ceremony cookie
        (`stellarindex_passkey_ceremony`) binding the challenge to
        this browser; `/auth/passkey/finish-login` requires it. The
        challenge is valid for 5 minutes — enforced server-side, not
        merely by the cookie's `Max-Age` — and is single-use.
      responses:
        "200":
          description: WebAuthn assertion options (challenge, rpId, …).
          headers:
            Set-Cookie:
              schema: { type: string }
              description: Signed ceremony cookie; challenge valid 5 minutes, single-use.
          content:
            application/json:
              schema:
                type: object
                description: |
                  A W3C `CredentialRequestOptions` document — the
                  `publicKey` member carries `challenge` (base64url),
                  `rpId`, `timeout`, and `userVerification`.
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/passkey/finish-login:
    post:
      operationId: finishPasskeyLogin
      tags: [auth]
      summary: Customer dashboard — finish a passkey sign-in; mints the session cookie.
      description: |
        Verifies the authenticator's assertion (the JSON produced by
        `navigator.credentials.get()`) against the ceremony cookie's
        challenge and the stored credential public key, then sets the
        SAME HttpOnly session cookie (`stellarindex_session`) the
        email-code and magic-link flows mint, and returns
        `{status:"ok"}`.

        The ceremony challenge is single-use: replaying a captured
        request (ceremony cookie plus the same assertion body) is
        refused, as is a challenge older than its 5-minute lifetime.

        Every verification failure — unknown credential, bad
        signature, missing user verification, expired challenge,
        already-used challenge, tampered ceremony cookie, or a
        sign-count regression (possible cloned authenticator; refused
        and logged) — returns the same generic 400 so a caller cannot
        probe which credentials exist.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                The serialized `PublicKeyCredential` assertion from
                `navigator.credentials.get()` (id, rawId, response
                fields base64url-encoded).
      responses:
        "200":
          description: Authenticated; session cookie set.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: HttpOnly + Secure session cookie.
          content:
            application/json:
              example:
                status: ok
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                required: [status]
        "400":
          description: Verification failed (generic — modes are indistinguishable).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403":
          description: |
            Cross-site write blocked: state-changing dashboard + auth
            requests must carry an `Origin` (or `Referer`) matching
            this API or an operator-allow-listed site
            (`cross-site-request-blocked`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: |
            The single-use challenge store could not be reached, so
            the ceremony's freshness could not be established. The
            sign-in is refused rather than granted on trust; email-code
            sign-in is unaffected.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/passkey/begin-register:
    post:
      operationId: beginPasskeyRegistration
      tags: [auth]
      summary: Customer dashboard — begin adding a passkey to the signed-in account.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Returns the WebAuthn credential-creation
        options for `navigator.credentials.create()` — resident
        (discoverable) key required, so the resulting passkey can
        later sign in usernameless, and `userVerification: required`,
        since that credential is a passwordless first factor and must
        carry a biometric/PIN of its own. An authenticator that
        cannot verify the user (a PIN-less security key) therefore
        cannot be enrolled. Already-registered credentials are
        excluded so an authenticator refuses to double-register.

        Sets the same signed ceremony cookie the login pair uses
        (purpose-bound: a registration challenge cannot finish a
        login, nor vice versa), with the same server-enforced
        5-minute, single-use challenge.
      responses:
        "200":
          description: WebAuthn creation options.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: Signed, short-lived ceremony cookie.
          content:
            application/json:
              schema:
                type: object
                description: |
                  A W3C `CredentialCreationOptions` document — the
                  `publicKey` member carries `challenge`, `rp`,
                  `user`, `pubKeyCredParams`, `excludeCredentials`,
                  and `authenticatorSelection`.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/passkey/finish-register:
    post:
      operationId: finishPasskeyRegistration
      tags: [auth]
      summary: Customer dashboard — finish adding a passkey; stores the credential.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Verifies the attestation response against the
        ceremony cookie's challenge (which must have been minted for
        THIS user, be within its 5-minute lifetime, and not already
        have been used) and stores the credential's public key. The
        private key never leaves the user's authenticator.

        A label longer than 100 characters is truncated to 100
        characters (not bytes — multi-byte names keep their full
        character budget).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: |
                    User-chosen label ("MacBook Touch ID"). Defaults
                    to "Passkey" when empty. Display-only.
                credential:
                  type: object
                  description: |
                    The serialized `PublicKeyCredential` attestation
                    from `navigator.credentials.create()`.
              required: [credential]
      responses:
        "200":
          description: Passkey stored.
          content:
            application/json:
              example:
                id: 9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8
                name: MacBook Touch ID
                transports: [internal]
                backup_eligible: true
                created_at: "2026-08-11T12:00:00Z"
              schema:
                $ref: "#/components/schemas/PasskeyCredential"
        "400":
          description: Verification failed (generic — modes are indistinguishable).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: This credential is already registered.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "500":
          description: |
            The single-use challenge store could not be reached, so
            the ceremony's freshness could not be established; the
            credential is not stored.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/passkey/credentials:
    get:
      operationId: listPasskeyCredentials
      tags: [auth]
      summary: Customer dashboard — list the signed-in user's passkeys.
      security:
        - SessionCookie: []
      description: |
        Session-gated. Display metadata only — credential IDs and
        public keys never leave the server.
      responses:
        "200":
          description: The user's registered passkeys, newest first.
          content:
            application/json:
              example:
                credentials:
                - id: 9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8
                  name: MacBook Touch ID
                  transports: [internal]
                  backup_eligible: true
                  created_at: "2026-08-11T12:00:00Z"
                  last_used_at: "2026-08-11T12:30:00Z"
              schema:
                type: object
                properties:
                  credentials:
                    type: array
                    items:
                      $ref: "#/components/schemas/PasskeyCredential"
                required: [credentials]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/passkey/credentials/{id}:
    delete:
      operationId: deletePasskeyCredential
      tags: [auth]
      summary: Customer dashboard — remove one of the signed-in user's passkeys.
      security:
        - SessionCookie: []
      description: |
        Session-gated and owner-scoped: a credential belonging to
        another user is indistinguishable from a nonexistent one
        (404 either way). Email-code sign-in always remains, so
        removing the last passkey cannot lock the account out.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The passkey's server-side id (from the list endpoint).
      responses:
        "204":
          description: Passkey removed.
        "400":
          description: Malformed id.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No such passkey on this account.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /auth/sep10/challenge:
    get:
      operationId: getSep10Challenge
      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:
      operationId: createSep10Token
      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:
              example:
                data:
                  token: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJHQjNZM0FMS0FQ...
                  expires_at: '2026-07-03T23:45:19.000000Z'
                  account: GB3Y3ALKAPYFMBEDS5G4KDDQBFV5FVJVGXWL4RPUCV3GTMOENIVXFAHW
                as_of: '2026-07-03T22:45:19.000000Z'
                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/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:
      operationId: listLedgers
      # x-stability: experimental marks the explorer/operator-only read
      # surface added under ADR-0038 and formalized as an OpenAPI
      # extension per ADR-0042 decision #4 (2026-07-08) — no wire-shape
      # promise across releases yet. This set is deliberately kept 1:1
      # with pkg/client/spec_contract_test.go's uncoveredOperations
      # "explorer surface — SDK is pricing-first" entries; if you add or
      # remove an operation from one list, update the other.
      x-stability: experimental
      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:
      operationId: getLedger
      x-stability: experimental
      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:
      operationId: getLedgerTransactions
      x-stability: experimental
      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:
      operationId: getTransaction
      x-stability: experimental
      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:
      operationId: listOperations
      x-stability: experimental
      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 }
                      coverage_note:
                        type: string
                        description: |
                          Present ONLY when the parent-transaction outcome read
                          failed: operations without `transaction_successful` are
                          then of UNKNOWN outcome (possibly a FAILED transaction),
                          not applied. Absent = every operation carries its true
                          transaction outcome.
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts:
    get:
      operationId: listContracts
      x-stability: experimental
      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.

        `days` is rounded UP to the nearest supported window — 1, 7, 30, 90 or
        365 — so the response is never narrower than what was asked for. The
        window actually aggregated is returned in `window_days` (with its floor
        in `since_ledger`); read those rather than echoing the request value.
      parameters:
        - name: days
          in: query
          description: |
            Window size in days, rounded UP to the nearest supported window
            (1, 7, 30, 90, 365). The served window is returned in `window_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:
      operationId: getContract
      x-stability: experimental
      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 full row-identity composite
        `(ledger, tx_hash, op_index, event_index)` — anything less is not
        unique (single-operation transactions dominate, so events from
        different transactions in one ledger tie on the shorter tuple) and
        would drop rows at a page boundary. `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. }
                      directory: { $ref: "#/components/schemas/DirectoryInfo" }
                      activity:
                        type: object
                        description: Liveness card — lifetime bounds + a 30-day daily active-ledger series. Absent when the activity index isn't provisioned.
                        properties:
                          first_seen: { type: string, format: date-time }
                          last_seen: { type: string, format: date-time }
                          active_ledgers_total: { type: integer, format: int64 }
                          daily:
                            type: array
                            items:
                              type: object
                              properties:
                                date: { type: string }
                                active_ledgers: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /contracts/{contract_id}/wasm:
    get:
      operationId: getContractWasm
      x-stability: experimental
      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:
      operationId: getContractInteractions
      x-stability: experimental
      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.

        `days` is an UPPER bound, not the window actually used. For a busy
        contract the window is narrowed to its own most recent activity so
        the read stays sub-second, which lowers the `shared_txs` counts
        while preserving the ranking. Always read `since_ledger` for the
        window actually served — it describes the real floor, which may be
        newer than `days` implies. The result is additionally computed over
        at most the contract's 50,000 most recent transactions in that
        window, so this endpoint reports a recent SAMPLE of behaviour
        rather than a lifetime total.
      parameters:
        - { name: contract_id, in: path, required: true, description: C-strkey contract id., schema: { type: string } }
        - { name: days, in: query, description: "Upper bound on window size in days. The served window may be narrower for busy contracts — read since_ledger.", 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
                              description: >-
                                Number of DISTINCT transactions in which this
                                contract and the subject both emitted events,
                                within the query window.


                                Corrected 2026-07-25 (audit DAT-10 / DAT-11):
                                this previously counted co-occurring EVENTS
                                rather than transactions, so a contract emitting
                                many events inside one shared transaction scored
                                its event count. It also double-counted rows from
                                a retried partial flush that the ReplacingMergeTree
                                had not yet merged. Values are therefore LOWER
                                than before for busy pairs — the old figure was an
                                event count wearing a transaction count's name.
                            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:
      operationId: getContractCodeHistory
      x-stability: experimental
      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:
      operationId: listAccounts
      x-stability: experimental
      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.

        Freshness (ADR-0041): `as_of_ledger` is the lake watermark this
        current-state ranking is fresh to (the highest captured ledger at
        serve time); `flags.stale` fires when the watermark's close time
        trails now by more than 300s (a wedged sink).
      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_ledger: 63340102
                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. }
                      as_of_ledger: { type: integer, format: int64, description: "Lake watermark this current-state ranking is fresh to (ADR-0041) — the highest captured ledger at serve time. Omitted when no watermark reader is wired. Pairs with flags.stale." }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /directory:
    get:
      operationId: getDirectoryLabels
      x-stability: experimental
      tags: [explorer]
      summary: Batch address labels from the curated public directory.
      description: |
        Resolve curated third-party labels (names, domains, tags) for up to
        100 Stellar addresses — G-accounts and C-contracts — in one request.
        The set is mirrored from the MIT-licensed
        [stellar-expert/public-directory](https://github.com/stellar-expert/public-directory)
        (~18.5k entries, resynced daily); listing is not endorsement, and the
        labels are display attribution, NOT a verification signal.

        `entries` is keyed by address and contains PRESENT addresses only —
        an address absent from the map has no label. Tags follow the upstream
        registry (`exchange`, `anchor`, `issuer`, `wallet`, `custodian`,
        `sdf`, `memo-required`, `airdrop`, `malicious`, `unsafe`, …); treat
        `malicious`/`unsafe` as warnings.

        Every address must be a checksum-valid G/C strkey — one malformed
        address fails the whole request with 400 so client bugs surface
        instead of silently dropping labels.
      parameters:
        - { name: addresses, in: query, required: true, description: "Comma-separated G/C strkeys (max 100).", schema: { type: string } }
      responses:
        "200":
          description: Labels for the listed subset of the requested addresses.
          content:
            application/json:
              example:
                data:
                  entries:
                    GDUY7J7A33TQWOSOQGDO776GGLM3UQERL4J3SPT56F6YS4ID7MLDERI4:
                      name: SDF Growth 3
                      domain: stellar.org
                      tags: [sdf, custodian]
                      source: stellar-expert
                as_of: '2026-08-06T12:00:00Z'
                flags:
                  stale: false
                  reduced_redundancy: false
                  triangulated: false
                  divergence_warning: false
                  divergence_checked: false
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [entries]
                    properties:
                      entries:
                        type: object
                        description: Address → label, present addresses only; always an object, never null.
                        additionalProperties: { $ref: "#/components/schemas/DirectoryInfo" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/stats:
    get:
      operationId: getAccountsStats
      x-stability: experimental
      tags: [explorer]
      summary: Network-wide account analytics — totals, wealth + trustline distribution, most-held assets.
      description: |
        Precomputed account analytics for the accounts hub, refreshed by a
        30-minute rollup cycle (`computed_at` stamps the cycle): funded
        account + trustline totals, XLM balance statistics (average /
        median / p90 / p99 — stroops as strings, ADR-0003), top-100
        concentration, a log10 wealth histogram (`bucket` = floor(log10 of
        the XLM balance); -1 is "< 1 XLM"), a trustlines-per-account band
        histogram (the "0" band is derived from the totals), and the
        most-held assets by holder count.

        503 with type `accounts-stats-warming` until the rollup's first
        cycle completes on a fresh deployment.
      responses:
        "200":
          description: The current analytics snapshot.
          content:
            application/json:
              example:
                data:
                  totals: { accounts: 9930118, trustlines: 19400000, trustline_holding_accounts: 3100000, xlm_held_stroops: "1049598291112345678" }
                  balances: { avg_stroops: "1056980000", median_stroops: "50000000", p90_stroops: "3200000000", p99_stroops: "91000000000" }
                  concentration: { top100_xlm_stroops: "512345678901234567", top100_share_pct: 48.82 }
                  wealth_histogram: [ { bucket: -1, accounts: 4100000, xlm_stroops: "9876543210" } ]
                  trustline_histogram: [ { bucket: "0", accounts: 6830118 }, { bucket: "1", accounts: 1400000 } ]
                  top_held_assets: [ { asset: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", holders: 619460 } ]
                  computed_at: "2026-08-08T19:30:00Z"
                as_of: "2026-08-08T19:45:00Z"
                flags: { stale: false, reduced_redundancy: false, triangulated: false, divergence_warning: false, divergence_checked: false }
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [totals, balances, concentration, wealth_histogram, trustline_histogram, computed_at]
                    properties:
                      totals:
                        type: object
                        properties:
                          accounts: { type: integer, format: int64 }
                          trustlines: { type: integer, format: int64 }
                          trustline_holding_accounts: { type: integer, format: int64 }
                          xlm_held_stroops: { type: string, description: Total XLM held by funded accounts, stroops (ADR-0003 string). }
                      balances:
                        type: object
                        properties:
                          avg_stroops: { type: string }
                          median_stroops: { type: string }
                          p90_stroops: { type: string }
                          p99_stroops: { type: string }
                      concentration:
                        type: object
                        properties:
                          top100_xlm_stroops: { type: string }
                          top100_share_pct: { type: number, description: Display convenience derived from the exact stroops figures. }
                      wealth_histogram:
                        type: array
                        items:
                          type: object
                          properties:
                            bucket: { type: integer, description: "floor(log10(balance in XLM)), clamped to [-1, 10]." }
                            accounts: { type: integer, format: int64 }
                            xlm_stroops: { type: string }
                      trustline_histogram:
                        type: array
                        items:
                          type: object
                          properties:
                            bucket: { type: string, description: 'Trustlines-per-account band: 0, 1, 2-5, 6-10, 11-50, or 50+.' }
                            accounts: { type: integer, format: int64 }
                      top_held_assets:
                        type: array
                        items:
                          type: object
                          properties:
                            asset: { type: string }
                            holders: { type: integer, format: int64 }
                      computed_at: { type: string, format: date-time, description: Rollup cycle timestamp — the snapshot's real age. }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}:
    get:
      operationId: getAccount
      x-stability: experimental
      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).

        Freshness (ADR-0041): `as_of_ledger` is the lake watermark the read
        is fresh to (NOT the account's `last_modified_ledger`); `flags.stale`
        fires when the watermark's close time trails now by more than 300s.
      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_ledger: 63340102
                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 }
                      as_of_ledger: { type: integer, format: int64, description: "Lake watermark this read is fresh to (ADR-0041) — the highest captured ledger at serve time, NOT the account's last_modified_ledger. Omitted when no watermark reader is wired. Pairs with flags.stale." }
                      directory: { $ref: "#/components/schemas/DirectoryInfo" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}/transactions:
    get:
      operationId: getAccountTransactions
      x-stability: experimental
      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:
      operationId: getAccountOperations
      x-stability: experimental
      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" }

  /accounts/{g_strkey}/movements:
    get:
      operationId: getAccountMovements
      x-stability: experimental
      tags: [explorer]
      summary: Unified account-activity feed (pre-P23 archive + post-P23 tail), newest first.
      description: |
        ADR-0048 D5: the ClickHouse `stellar.account_movements` pre-P23
        classic-movement archive (ADR-0047/0048 D2) merged, at read time,
        with the Postgres `sep41_transfers` post-P23 "recent tail" — the
        one feed spanning an address's ENTIRE classic-asset movement
        history, not just its recent SEP-41 activity. Newest first,
        keyset-paged with `?cursor=<opaque>` (echo back `next_cursor`); the
        cursor is the composite `(ledger, tx_hash, op_index, leg_index)`.

        MERGE SEAM: the two stores' ledger ranges never overlap by
        construction — the ClickHouse archive's only writer
        (`classic-movements-backfill`) hard-clamps below the P23 boundary
        (ledger 58,762,517, Whisk/CAP-67, 2025-09-03); the Postgres tail
        query floors at-or-above it. The server asserts this invariant on
        every request (logs an error if ever violated) rather than only
        documenting it.

        SCOPE GAP (documented, not a bug): the Postgres tail only surfaces
        `sep41_transfers` rows with `event_kind = 'transfer'` — a pure
        Soroban-native SEP-41 token transfer that happened BEFORE the P23
        boundary is real on-chain activity this feed does not (yet)
        surface; only CLASSIC-asset movements are covered end-to-end.

        HONEST EMPTY STATE: `classic-movements-backfill` is an
        operator-run historical job (never live-wired) — on a fresh
        deployment `stellar.account_movements` is EMPTY until an operator
        runs it, so this endpoint may serve the Postgres tail alone. See
        `coverage_note` on the response.

        `?asset=` FILTER ASYMMETRY (documented): on ClickHouse archive rows
        it matches the canonical asset id exactly (`CODE-ISSUER` /
        `native` / `pool:<hex>`); on the Postgres tail it matches against
        the RESOLVED display value (the classic-asset name for a SAC
        wrapper, else the raw token contract_id) — resolved and filtered
        client-request-side, not in SQL, so a page may return fewer than
        `limit` Postgres-tail rows even when more matching rows exist
        further back. An accepted limitation of this experimental
        endpoint's Postgres half.
      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 25). Out-of-range values return 400.", schema: { type: integer, minimum: 1, maximum: 200, default: 25 } }
        - { name: cursor, in: query, description: Opaque keyset cursor from a prior response's next_cursor., schema: { type: string } }
        - { name: kind, in: query, description: "Filter by movement_kind exact match (e.g. payment, transfer, liquidity_pool_deposit). Omitted = any kind.", schema: { type: string } }
        - { name: direction, in: query, description: Filter by direction., schema: { type: string, enum: [sent, received, self] } }
        - { name: asset, in: query, description: "Filter by asset — see this operation's description for the matching asymmetry between the ClickHouse archive and the Postgres tail.", schema: { type: string } }
      responses:
        "200":
          description: The account's unified movement feed.
          content:
            application/json:
              example:
                data:
                  account: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  movements:
                  - ledger: 60123456
                    ledger_close_time: '2026-07-03T21:02:29Z'
                    tx_hash: be8ac09cf011950987ae7c17badec336ccf24782a03f5573b1f982cb44c98f36
                    op_index: 0
                    leg_index: 0
                    movement_kind: transfer
                    direction: sent
                    asset: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                    amount: '1000000000000'
                    counterparty: GDSQAEHJLE2ZZMQZ47YWLP3O2HVPYQ4QCFWTHUKMKF6RIX2ZJJDDMK4N
                    provenance: cap67_event
                  next_cursor: '60123456.be8ac09cf011950987ae7c17badec336ccf24782a03f5573b1f982cb44c98f36.0.0'
                as_of: '2026-07-10T22: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/AccountMovements" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}/positions:
    get:
      operationId: getAccountPositions
      x-stability: experimental
      tags: [explorer]
      summary: "\"Enter an address, see all your DeFi positions\" — folded per-protocol net positions."
      description: |
        Folds six ADR-0035-gated on-chain protocols' event tables into
        one per-(venue, position_kind) net-position list for the
        address: Blend money-market (lending_supply / lending_borrow),
        Blend Backstop (backstop_shares), Phoenix stake
        (stake), DeFindex vault shares (vault_shares), sorocredit
        (credit), and Aquarius rewards-gauge (gauge). Not paginated —
        each protocol's venue fan-out for one address is bounded server-
        side (500 venues), far past any realistic user.

        HONESTY CONTRACT: this is raw on-chain quantities only — no
        USD/asset valuation is applied (see the top-level `note`, always
        present). Coverage is disclosed separately: if any of the six
        per-protocol reads fails, the response carries a
        `coverage_note` naming which folds are missing, so a partial
        answer can never be mistaken for "this address holds nothing". Every position also carries `amount_semantics`
        (exactly what the number IS — see the schema) and `basis`
        (`event_derived`: a sum of this fold's own historical event
        amounts, computed here, that does NOT model interest/fees/
        exchange-rate accrual since each event; or `stateful`: read
        straight from the protocol's own most-recently-published figure,
        not derived by summing events — currently only sorocredit).

        NET-ZERO / CLOSED positions are excluded by default (what
        "net-zero" means is protocol-specific — see each `position_kind`
        below); `?include_closed=true` includes them.
      parameters:
        - { name: g_strkey, in: path, required: true, description: G-strkey account id., schema: { type: string } }
        - { name: include_closed, in: query, description: "Include net-zero / closed positions (excluded by default).", schema: { type: boolean, default: false } }
      responses:
        "200":
          description: The account's folded DeFi positions.
          content:
            application/json:
              example:
                data:
                  account: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  positions:
                  - protocol: blend
                    position_kind: lending_supply
                    venue: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
                    venue_label: USDC/XLM
                    assets: [USDC]
                    amount: '600000000'
                    amount_semantics: net_underlying_at_event_time
                    last_activity: { ledger: 63316200, time: '2026-07-10T21:02:29Z' }
                    basis: event_derived
                  - protocol: sorocredit
                    position_kind: credit
                    venue: CCG5EWFY2KCWWYYEIUMIRG6WSAQFLDR5QE5FMCWY25N36XA5GYTCPQWR
                    assets: [USDC]
                    amount: '480000000'
                    amount_semantics: stateful_current
                    last_activity: { ledger: 63316350, time: '2026-07-10T21:40:11Z' }
                    basis: stateful
                  include_closed: false
                  note: >-
                    Amounts are on-chain quantities only — no USD or other valuation is
                    applied. event_derived positions are a sum of historical per-event
                    amounts and do NOT model interest, fees, or exchange-rate accrual
                    since each event; see each position's amount_semantics for exactly
                    what the number represents, and basis for whether it was derived
                    here or read from the protocol's own published state.
                as_of: '2026-07-10T22: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/AccountPositions" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}/trades:
    get:
      operationId: getAccountTrades
      x-stability: experimental
      tags: [explorer]
      summary: The address's historic trades (taker or maker side), newest first.
      description: |
        Per-address historic trades out of the served-tier `trades`
        hypertable, newest first, keyset-paged with `?cursor=<opaque>`
        (echo back `next_cursor`); the cursor is the composite
        `(ts, ledger, tx_hash, op_index)`.

        ATTRIBUTION SCOPE (see the always-present `note`): the trades
        table attributes accounts via its taker/maker columns — `taker`
        is the acting account (the op/tx source for sdex; the user
        address for aquarius, phoenix, comet), `maker` is the resting
        sdex offer owner. Soroswap swaps do not record the acting
        account and off-chain CEX/FX trades carry no Stellar account,
        so neither can appear here; an empty page therefore means "no
        ATTRIBUTED trades", not "this address never traded anywhere".

        Amounts are raw integer amounts at each asset's own on-chain
        scale, as decimal strings (ADR-0003). There is deliberately no
        price field: the table stores no price and deriving one needs
        per-asset decimals. `usd_volume` is present only when the
        aggregator valued the trade at ingest time — absent means
        "unknown", never zero.
      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: The account's attributed trades.
          content:
            application/json:
              example:
                data:
                  account: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  trades:
                  - ts: '2026-07-29T12:00:00Z'
                    source: sdex
                    base_asset: native
                    quote_asset: USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                    base_amount: '10000000'
                    quote_amount: '1234567'
                    usd_volume: '0.12345600'
                    tx_hash: be8ac09cf011950987ae7c17badec336ccf24782a03f5573b1f982cb44c98f36
                    ledger: 63000001
                    op_index: 0
                    role: taker
                  next_cursor: '1785340800000000000.63000001.be8ac09cf011950987ae7c17badec336ccf24782a03f5573b1f982cb44c98f36.0'
                  note: >-
                    On-chain trades where this address is recorded as the acting account
                    (taker) or the resting sdex offer owner (maker). Coverage: sdex,
                    aquarius, phoenix, comet. Soroswap swaps do not yet record the acting
                    account and off-chain CEX/FX trades carry no Stellar account, so
                    neither can appear here.
                as_of: '2026-07-30T22: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/AccountTrades" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /accounts/{g_strkey}/activity:
    get:
      operationId: getAccountActivity
      x-stability: experimental
      tags: [explorer]
      summary: Segmented "what has this address been doing" breakdown (all-time counts).
      description: |
        One aggregate object per address: all-time per-op-type operation
        counts (sourced + participant, from the certified ClickHouse
        lake), attributed-trade total, per-(protocol, action) DeFi event
        counts, and bridge-transfer counts (Postgres served tier).

        CACHING: every segment is a whole-history aggregate, so the
        payload is served stale-while-revalidate — an expired entry is
        served immediately with `flags.stale: true` and its real `as_of`
        while one detached recompute runs; a never-computed address may
        503 its first request (retry lands warm). The envelope's `as_of`
        is the payload's computation time, not now.

        HONESTY CONTRACT: a segment that could not be read is ABSENT
        from the response and named in `coverage_note` — never
        zero-filled. `bridge_transfers.note` documents exactly how
        bridge rows attribute to an address (and which directions
        structurally cannot).
      parameters:
        - { name: g_strkey, in: path, required: true, description: G-strkey account id., schema: { type: string } }
      responses:
        "200":
          description: The account's segmented activity breakdown.
          content:
            application/json:
              example:
                data:
                  account: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN
                  ops_by_type:
                  - { op_type: payment, count: 120 }
                  - { op_type: manage_buy_offer, count: 14 }
                  trades_total: 77
                  defi_actions:
                  - { protocol: blend, action: supply, count: 3 }
                  - { protocol: aquarius, action: position_update, count: 9 }
                  bridge_transfers:
                    rozo_outbound_payments: 1
                    rozo_inbound_payments: 0
                    cctp_outbound_burns: 2
                    cctp_inbound_mints: 0
                    note: >-
                      rozo matches payment from_addr (outbound) / destination (inbound).
                      cctp matches deposit_for_burn depositor (outbound) and the
                      Stellar-side mint_and_withdraw mint recipient (inbound) — an
                      inbound mint delivered via a forwarder contract attributes to that
                      contract, not the end user, and an outbound burn's
                      destination-chain recipient is not a Stellar account so it is
                      never matched.
                as_of: '2026-07-30T22: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/AccountActivity" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /search:
    get:
      operationId: classifySearchQuery
      x-stability: experimental
      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 volume-weighted
        price series. `twap` returns the time-weighted price series
        from the `twap_1h` / `twap_1d` continuous aggregates
        (time-weighted at 1-minute resolution; served at 1h or 1d
        granularity, the requested granularity snapped onto the
        nearer of the two). `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:
    PasskeyCredential:
      type: object
      description: >
        One registered passkey (WebAuthn credential) on the signed-in
        user's account. Display metadata only — the credential ID and
        public key never leave the server, and the private key never
        left the user's authenticator.
      required: [id, name, backup_eligible, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: Server-side row id — the handle DELETE takes.
        name:
          type: string
          description: User-chosen label ("MacBook Touch ID").
        transports:
          type: array
          items: { type: string }
          description: >
            Authenticator-reported transports (usb / nfc / ble /
            internal / hybrid). Hints only; may be absent.
        backup_eligible:
          type: boolean
          description: >
            Whether the credential is a synced (multi-device)
            passkey rather than bound to one authenticator.
        created_at: { type: string, format: date-time }
        last_used_at:
          type: string
          format: date-time
          description: Last successful sign-in with this passkey; absent when never used.

    # ───────────────── Network explorer (ADR-0038) ─────────────────
    DirectoryInfo:
      type: object
      description: >
        Curated third-party label for a Stellar address, mirrored from the
        MIT-licensed stellar-expert/public-directory set. Display attribution
        only — listing is not endorsement and this is NOT a verification
        signal. Tags follow the upstream registry (exchange, anchor, issuer,
        wallet, custodian, sdf, memo-required, airdrop, malicious, unsafe, …);
        treat `malicious`/`unsafe` as warnings worth surfacing prominently.
      required: [name, tags, source]
      properties:
        name: { type: string, description: Human label, e.g. "SDF Growth 3". }
        domain: { type: string, description: Domain the upstream set associates with the address; absent when none. }
        tags: { type: array, items: { type: string }, description: Upstream tag registry values (always present, possibly empty). }
        source: { type: string, description: Upstream set the label came from ("stellar-expert"). }
    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, description: Whether the transaction applied. Failed transactions ARE indexed and served (an on-chain, fee-charged record). }
        result_code: { type: integer, description: Raw XDR TransactionResultCode (0 = success; negatives are failure reasons). }
        result: { type: string, description: "Human-readable slug for result_code (e.g. tx_success, tx_failed, tx_insufficient_fee). Always present." }
        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. }
        transaction_successful:
          type: boolean
          description: |
            Whether this operation's PARENT transaction applied — the honesty
            signal on the list views (account history, ledger op list,
            /operations directory) where an operation is shown outside its
            transaction. false ⇒ the transaction did NOT apply and nothing this
            operation names actually moved. OMITTED (not false) when the parent
            outcome was not read — a degraded response disclosed via the view's
            coverage_note; absence is UNKNOWN, never "successful".
        transaction_result:
          type: string
          description: |
            Human-readable slug for the parent transaction's result (e.g.
            tx_success, tx_failed, tx_insufficient_fee) — the authoritative WHY
            paired with transaction_successful. Omitted when the parent outcome
            was not read.
        result_code: { type: integer, description: The operation's OUTER XDR OperationResultCode. Present in the per-transaction view. }
        result: { type: string, description: "Human-readable slug for result_code (e.g. op_inner, op_bad_auth, op_no_source_account). Present with result_code." }
    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" } }
            coverage_note:
              type: string
              description: |
                Honest-degrade signal (mirrors the AccountMovements
                coverage_note pattern), present ONLY when a non-fatal
                sub-read failed while assembling this response:
                  - the per-operation result-code read errored — the
                    operations shown carry no `result_code`, which is
                    NOT a successful/zero result;
                  - the contract-event read errored — the `events`
                    array is incomplete, NOT genuinely empty.
                Both `events` and each op's `result_code` are omitted
                when absent, so without this note a failed read is
                indistinguishable on the wire from a real empty.
                Absent = every sub-read succeeded and empty arrays /
                absent codes are real.
    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." }
        coverage_note:
          type: string
          description: |
            Honest-degrade signal, present ONLY when the parent-transaction
            outcome read failed while assembling this page: operations without
            `transaction_successful` are then of UNKNOWN outcome and may belong
            to a FAILED transaction, not applied. Failed transactions ARE listed
            here (on-chain, fee-charged records), so this marker is what keeps a
            failed operation from masquerading as a real interaction. Absent =
            every operation carries its true transaction outcome.
    AccountMovement:
      type: object
      description: One row in an account's movement feed (ADR-0048 D5).
      properties:
        ledger: { type: integer }
        ledger_close_time: { type: string, format: date-time }
        tx_hash: { type: string }
        op_index: { type: integer }
        leg_index:
          type: integer
          description: |
            Sub-index within the op — one movement can fan out to more than
            one row sharing (ledger, tx_hash, op_index) — e.g. each leg of a
            liquidity-pool deposit/withdraw (2 rows), or the two
            claimable-balance rows an auto-liquidation creates.
        movement_kind:
          type: string
          description: |
            payment, create_account, path_payment, clawback, account_merge,
            claimable_balance_create, claimable_balance_claim,
            claimable_balance_clawback, liquidity_pool_deposit, or
            liquidity_pool_withdraw on ClickHouse pre-P23 archive rows
            (`provenance: classic_derived`); transfer on Postgres post-P23
            tail rows (`provenance: cap67_event`).
        direction: { type: string, enum: [sent, received, self] }
        asset:
          type: string
          description: |
            Canonical asset id (`CODE-ISSUER` / `native` / `pool:<hex>`) on
            pre-P23 rows. On post-P23 rows: the resolved classic-asset name
            when the SEP-41 token is a SAC wrapper, else the raw Soroban
            token contract_id (a genuine Soroban-native token has no
            classic-asset name to resolve to) — see this operation's
            description for the ?asset= filter's matching asymmetry between
            the two sides.
        amount: { type: string, description: "Decimal string (ADR-0003) at the asset's native smallest-unit scale." }
        counterparty:
          type: string
          description: |
            The other side's address, when known. Empty for a claimable-
            balance escrow or a liquidity-pool leg (neither is a real
            G-account) — see stellar.account_movements' DDL comment
            (deploy/clickhouse/tier1_schema.sql) for the full per-kind
            cardinality table.
        provenance:
          type: string
          enum: [classic_derived, cap67_event]
          description: |
            classic_derived = reconstructed from the ClickHouse pre-P23 lake
            (ADR-0047); cap67_event = a post-P23 CAP-67 unified event read
            from the Postgres sep41_transfers "recent tail" (ADR-0048 D5).
        attributes:
          type: object
          additionalProperties: true
          description: Kind-specific remainder (balance_id, claimants, pool_id, revocation, …). Empty/absent on transfer rows.
      required: [ledger, ledger_close_time, tx_hash, op_index, leg_index, movement_kind, direction, asset, amount, provenance]
    AccountActivity:
      type: object
      description: |
        Segmented all-time activity breakdown for one address. Segments
        that could not be read are ABSENT (never zero-filled) and named
        in coverage_note.
      properties:
        account: { type: string, description: The G-strkey this breakdown is for. }
        ops_by_type:
          type: array
          items: { $ref: "#/components/schemas/AccountOpTypeCount" }
          description: |
            All-time per-op-type operation counts INVOLVING the account
            — both sourced and non-source-participant (participant-side
            coverage tracks the lake's participant-index capture +
            backfill, same caveat as /accounts/{g_strkey}/operations).
            Sorted by count desc.
        trades_total:
          type: integer
          format: int64
          description: |
            All-time attributed trade count (taker or maker side) — the
            same attribution scope /accounts/{g_strkey}/trades documents
            (soroswap + off-chain trades carry no account and are not
            counted). Absent = the segment could not be read, not zero.
        defi_actions:
          type: array
          items: { $ref: "#/components/schemas/AccountDefiActionCount" }
          description: |
            Per-(protocol, action) DeFi event counts over the served-tier
            tables that genuinely carry a per-account column: blend
            money-market, blend_backstop, blend_auctions, phoenix_stake,
            defindex (vault layer), sorocredit (positions opened), and
            aquarius rewards. Tables with no per-account column
            (phoenix/aquarius/comet pool-level liquidity, soroswap) are
            honestly out of scope, not zero.
        bridge_transfers:
          $ref: "#/components/schemas/AccountBridgeTransfers"
        coverage_note:
          type: string
          description: |
            ABSENT on a fully-successful read. Present when one or more
            segments could not be read — those segments are MISSING from
            this response, not zero. Mirrors the coverage_note contract
            on the movements/positions siblings.
      required: [account]
    AccountOpTypeCount:
      type: object
      description: One op-type's all-time count for the account.
      properties:
        op_type: { type: string }
        count: { type: integer, format: int64 }
      required: [op_type, count]
    AccountDefiActionCount:
      type: object
      description: One (protocol, action) DeFi event count for the account.
      properties:
        protocol:
          type: string
          description: |
            Which per-protocol table the count comes from — blend,
            blend_backstop, blend_auctions, phoenix_stake, defindex,
            sorocredit, or aquarius.
        action:
          type: string
          description: The table's own action/event-kind discriminator (e.g. supply, borrow, bond, deposit, position_update).
        count: { type: integer, format: int64 }
      required: [protocol, action, count]
    AccountBridgeTransfers:
      type: object
      description: Per-bridge, per-direction transfer counts for the account.
      properties:
        rozo_outbound_payments: { type: integer, format: int64 }
        rozo_inbound_payments: { type: integer, format: int64 }
        cctp_outbound_burns: { type: integer, format: int64 }
        cctp_inbound_mints: { type: integer, format: int64 }
        note:
          type: string
          description: |
            Always present — documents exactly how bridge rows attribute
            to an address, including the structural non-matches (an
            outbound CCTP burn's destination-chain recipient is not a
            Stellar account; an inbound mint delivered via a forwarder
            contract attributes to that contract, not the end user).
      required: [rozo_outbound_payments, rozo_inbound_payments, cctp_outbound_burns, cctp_inbound_mints, note]
    AccountMovements:
      type: object
      description: |
        An account's unified movement feed (ADR-0048 D5) — the ClickHouse
        pre-P23 classic-movement archive (stellar.account_movements)
        merged, at read time, with the Postgres sep41_transfers post-P23
        "recent tail". Newest first, keyset-paged. See coverage_note for
        the endpoint's honest-degrade contract.
      properties:
        account: { type: string, description: The G-strkey this listing is for. }
        movements: { type: array, items: { $ref: "#/components/schemas/AccountMovement" } }
        next_cursor: { type: string, description: "Opaque composite cursor (ledger.tx_hash.op_index.leg_index) for the next older page; absent on the last page." }
        coverage_note:
          type: string
          description: |
            Honest-degrade signal (mirrors the routed_via/aggregators
            coverage-note pattern), present ONLY when this response is
            NOT the full ADR-0048 D5 merge:
              - the ClickHouse pre-P23 archive is populated by an
                operator-run historical backfill
                (`stellarindex-ops classic-movements-backfill`) and is
                EMPTY on a fresh deployment until that job runs — a
                response may therefore be Postgres-tail-only;
              - the Postgres tail reader isn't wired on this deployment,
                or the read errored for this request — a response may
                therefore be ClickHouse-archive-only.
            Absent = both sides contributed to this response.
      required: [account, movements]
    AccountPosition:
      type: object
      description: One folded DeFi position — a net position in a single venue for a single protocol.
      properties:
        protocol:
          type: string
          enum: [blend, phoenix, defindex, sorocredit, aquarius]
        position_kind:
          type: string
          enum: [lending_supply, lending_borrow, backstop_shares, stake, vault_shares, credit, gauge]
          description: |
            lending_supply/lending_borrow: Blend money-market, independent
            supply-side and borrow-side legs of the same (pool, asset).
            backstop_shares: Blend Backstop LP-share stake. stake: Phoenix
            per-pool LP-share bond. vault_shares: DeFindex vault df-token
            share. credit: sorocredit CDP-style credit position. gauge:
            Aquarius rewards-gauge staked position.
        venue:
          type: string
          description: The pool / vault / stake-contract / collateral-position contract C-strkey this position is held in.
        venue_label:
          type: string
          description: |
            Best-effort human label for `venue` (e.g. "USDC/XLM" for a
            Blend pool's reserve set, resolved via the same pool-tokens
            machinery the /v1/protocols/{name} roster's pair labels use).
            Absent when no label could be resolved — never a guess.
        assets:
          type: array
          items: { type: string }
          description: |
            Best-effort resolved asset display code(s) held in this
            position (e.g. "USDC", "XLM"). Absent when the underlying
            table carries no resolvable asset identity for this
            position_kind (documented per-protocol in
            internal/api/v1/explorer/positions.go).
        amount:
          type: string
          description: Decimal string (ADR-0003) — see amount_semantics for exactly what this number represents.
        amount_semantics:
          type: string
          description: |
            REQUIRED documentation of what `amount` IS — determined per
            source by reading the decoder's own semantics, never
            guessed:
              - net_underlying_at_event_time: a sum of historical
                UNDERLYING-asset amounts observed at each contributing
                event (Blend supply/borrow). Does NOT reflect interest
                accrued since each event, and is NOT the b/d-token
                amount.
              - shares: an exact current count of a protocol's own
                share/LP token, minted/burned 1:1 with the summed events
                (Blend Backstop, Phoenix stake, DeFindex vault shares).
              - stateful_current: the protocol's own most-recently
                PUBLISHED figure (sorocredit's latest statement amount),
                not a delta sum this endpoint computed.
              - signed_delta_sum_unconfirmed_unit: a sum of signed
                per-event deltas whose unit is not contract-source-
                confirmed (Aquarius gauge position_update — see
                internal/sources/aquarius/decode_rewards.go's
                best-effort field-mapping doc comment).
          enum: [net_underlying_at_event_time, shares, stateful_current, signed_delta_sum_unconfirmed_unit]
        last_activity:
          type: object
          description: The most recent contributing event's ledger + close time.
          properties:
            ledger: { type: integer }
            time: { type: string, format: date-time }
          required: [ledger]
        basis:
          type: string
          enum: [event_derived, stateful]
          description: |
            event_derived: computed by this endpoint by summing the
            fold's own event log. stateful: read from a field the
            protocol itself last published, not derived by summing
            events (sorocredit only, today).
      required: [protocol, position_kind, venue, amount, amount_semantics, last_activity, basis]
    AccountPositions:
      type: object
      description: |
        An account's folded DeFi positions across every ADR-0035-gated
        on-chain protocol this endpoint reads. Not paginated (see the
        operation description for the venue-cardinality bound). Always
        carries `note` — the valuation/accrual honesty contract applies
        even to an empty result.
      properties:
        account: { type: string, description: The G-strkey this listing is for. }
        positions: { type: array, items: { $ref: "#/components/schemas/AccountPosition" } }
        include_closed: { type: boolean, description: "Echoes the ?include_closed= request parameter." }
        note:
          type: string
          description: |
            Always present. States plainly that amounts are raw on-chain
            quantities with no valuation applied, and that event_derived
            positions do not model interest/fee/exchange-rate accrual —
            see each position's amount_semantics / basis for specifics.
        coverage_note:
          type: string
          description: |
            ABSENT on a fully-successful read. Present when one or more
            of the six per-protocol folds could not be read for this
            request — it names how many failed, out of how many, and
            which ones. Positions from a named protocol are MISSING
            from the response, not proven absent on-chain: an empty
            `positions` array with a `coverage_note` means "we could
            not look", never "this address holds nothing". Mirrors the
            same field on GET /accounts/{g_strkey}/movements.
      required: [account, positions, include_closed, note]
    AccountTrade:
      type: object
      description: |
        One attributed trade for the address. Amounts are raw integer
        amounts at each asset's own on-chain scale, as decimal strings
        (ADR-0003). No price field is served — the underlying table
        stores none and deriving one needs per-asset decimals.
      properties:
        ts: { type: string, format: date-time }
        source:
          type: string
          description: The venue the trade executed on (sdex, aquarius, phoenix, comet).
        base_asset: { type: string, description: Canonical base asset id (native | CODE-ISSUER | C…). }
        quote_asset: { type: string, description: Canonical quote asset id. }
        base_amount: { type: string, description: Decimal string, raw integer at the base asset's scale. }
        quote_amount: { type: string, description: Decimal string, raw integer at the quote asset's scale. }
        usd_volume:
          type: string
          description: |
            USD-equivalent volume as a decimal string, when the
            aggregator valued the trade at ingest. ABSENT = unknown
            (never zero-filled).
        tx_hash: { type: string }
        ledger: { type: integer }
        op_index: { type: integer }
        role:
          type: string
          enum: [taker, maker]
          description: Which side of the trade this address was recorded on.
        counterparty:
          type: string
          description: The other recorded account, when the venue recorded one (sdex only, today).
        routed_via:
          type: string
          description: |
            Router contract attribution (migration 0025), when present.
            Descriptive, first-wins metadata from the trade's own routing
            structure — not authenticated provenance and not a price input
            (see the same field on the trade-history schema).
      required: [ts, source, base_asset, quote_asset, base_amount, quote_amount, tx_hash, ledger, op_index, role]
    AccountTrades:
      type: object
      description: |
        An address's attributed historic trades, newest first,
        keyset-paged. Always carries `note` — the taker/maker
        attribution-scope contract applies even to an empty result.
      properties:
        account: { type: string, description: The G-strkey this listing is for. }
        trades: { type: array, items: { $ref: "#/components/schemas/AccountTrade" } }
        next_cursor: { type: string, description: "Opaque composite cursor (ts.ledger.tx_hash.op_index) for the next older page; absent on the last page." }
        note:
          type: string
          description: |
            Always present. States which venues CAN attribute trades to
            an account (sdex/aquarius/phoenix/comet via taker/maker) and
            which structurally cannot (soroswap, off-chain CEX/FX) — an
            empty page means "no attributed trades", not "never traded".
      required: [account, trades, note]
    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 }
        scopes:
          type: array
          items: { type: string }
          description: |
            Capability scopes (read / account / dashboard / admin).
            Absent/empty = full access.
        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:    1000/min
              - partner: 100000/min ceiling (the staff-set
                per-account override is the real limit)

            A request of 100000 on a free account persists 1000.
            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
        scopes:
          type: array
          items:
            type: string
            enum: [read, account, dashboard, admin]
          description: |
            Optional capability scopes confining the key to route
            families. Empty = full access (back-compat posture every
            pre-scopes key keeps). Unknown values 400.
        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. The worker POSTs JSON with an HMAC-SHA-256
            signature in `X-StellarIndex-Signature` (prefixed `sha256=`)
            and the signing time in `X-StellarIndex-Timestamp`. See
            `CreateWebhookResponse.secret` for the exact construction —
            the timestamp is part of the signed message.
        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`),
            `DivergenceFiringWebhookPayload` (`divergence.firing`), and
            `PriceAlertWebhookPayload` (`price.alert`).
          items:
            type: string
            enum:
              - incident.sev1
              - incident.resolved
              - anomaly.freeze
              - divergence.firing
              - price.alert
        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, price.alert]
        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 and use it to verify the
            `X-StellarIndex-Signature` header on inbound webhook POSTs.
            Format: `wsec_<64 hex chars>`.

            VERIFICATION (the signed message is NOT the body alone):

              signed_message = "<X-StellarIndex-Timestamp>" + "." + <raw request body>
              expected       = "sha256=" + hex(HMAC_SHA256(secret, signed_message))

            Compare `expected` against `X-StellarIndex-Signature` in
            constant time. Note the literal `sha256=` prefix on the
            header value, and that the timestamp is bound INTO the MAC —
            verifying the body alone will fail on every delivery.

            REPLAY: `X-StellarIndex-Timestamp` is Unix seconds at signing
            time. Reject deliveries whose timestamp is outside a
            tolerance window you choose (5 minutes is typical). Binding
            the timestamp only prevents an attacker altering it; nothing
            stops a captured delivery being replayed unless you check it.

            DEDUPE: `X-StellarIndex-Delivery-Id` is stable across retries
            of the same delivery. It is NOT stable across a re-emit of
            the same logical event, so it dedupes retries, not re-sends.
      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, price.alert]
        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, price.alert] }
        attempt_count:        { type: integer, minimum: 0 }
        next_attempt_at:      { type: [string, "null"], format: date-time, description: "When the worker will retry. Null/zero when terminal (delivered OR retry budget exhausted)." }
        delivered_at:         { type: [string, "null"], format: date-time }
        last_error:           { type: [string, "null"] }
        last_response_status: { type: [integer, "null"], description: "HTTP status of the most-recent attempt." }
        created_at:           { type: string, format: date-time }
      required: [id, event_type, attempt_count, created_at]

    DashboardPriceAlert:
      type: object
      description: |
        A customer-registered price-threshold alert backing the
        /v1/dashboard/price-alerts surface (BACKLOG #60). The
        aggregator's evaluator compares each enabled alert against the
        latest closed 1m VWAP for its pair and, on a crossing, enqueues
        a `price.alert` webhook delivery to the account's subscribed
        webhooks.
      properties:
        id:               { type: string, format: uuid }
        base_asset:       { type: string, description: "Canonical base asset id (`native`, `USDC-G…`, `C…`, `fiat:USD`)." }
        quote_asset:      { type: string, description: "Canonical quote asset id." }
        condition:        { type: string, enum: [above, below], description: "`above` fires at observed >= threshold; `below` at observed <= threshold." }
        threshold:        { type: string, description: "Price boundary as a decimal string (never a float — ADR-0003)." }
        cooldown_seconds: { type: integer, minimum: 0, description: "Minimum seconds between two fires of this alert. 0 = re-fire every tick the condition holds." }
        enabled:          { type: boolean }
        last_fired_at:    { type: string, format: date-time, description: "When the alert last enqueued a delivery. Absent/zero = never fired." }
        created_at:       { type: string, format: date-time }
        updated_at:       { type: string, format: date-time }
      required: [id, base_asset, quote_asset, condition, threshold, cooldown_seconds, enabled, created_at, updated_at]

    CreatePriceAlertRequest:
      type: object
      properties:
        base_asset:       { type: string, description: "Canonical base asset id." }
        quote_asset:      { type: string, description: "Canonical quote asset id." }
        condition:        { type: string, enum: [above, below] }
        threshold:        { type: string, description: "Positive decimal string (e.g. \"0.15\", \"1200\"). Fractions / scientific notation are rejected." }
        cooldown_seconds: { type: integer, minimum: 0, description: "Optional; defaults to 300 (5m) when omitted. Send an explicit 0 to opt into re-firing every tick the condition holds." }
        enabled:          { type: boolean, description: "Defaults true when absent." }
      required: [base_asset, quote_asset, condition, threshold]

    UpdatePriceAlertRequest:
      type: object
      description: |
        PATCH body — any subset of fields. Omitted fields keep their
        current value.
      properties:
        base_asset:       { type: string }
        quote_asset:      { type: string }
        condition:        { type: string, enum: [above, below] }
        threshold:        { type: string }
        cooldown_seconds: { type: integer, minimum: 0 }
        enabled:          { type: boolean }

    # ──────────────────────────────────────────────────────────
    # 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, "null"], format: date-time, 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, "null"], 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]

    PriceAlertWebhookPayload:
      type: object
      description: |
        Body of a `price.alert` webhook delivery (BACKLOG #60). Fired by
        the aggregator's price-alert evaluator when one of the account's
        registered alerts crosses its threshold against the latest closed
        1-minute VWAP. Unlike the operational events, this is enqueued
        ONLY to the owning account's subscribed webhooks.
      properties:
        event:          { type: string, enum: [price.alert] }
        alert_id:       { type: string, format: uuid, description: "The price_alerts row that fired." }
        pair:           { type: string, description: "Canonical `<base>/<quote>` (e.g. `native/fiat:USD`)." }
        base_asset:     { type: string, description: "Canonical base asset id." }
        quote_asset:    { type: string, description: "Canonical quote asset id." }
        condition:      { type: string, enum: [above, below] }
        threshold:      { type: string, description: "The configured threshold (decimal-as-string)." }
        observed_price: { type: string, description: "The closed-bucket VWAP that crossed the threshold (decimal-as-string)." }
        bucket:         { type: string, format: date-time, description: "Close time of the observed 1-minute VWAP bucket." }
        at:             { type: string, format: date-time, description: "When this delivery was generated (server time, RFC 3339 nanosecond)." }
      required: [event, alert_id, pair, base_asset, quote_asset, condition, threshold, observed_price, bucket, 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.
        - `diverged` — set on a TRIANGULATED `/v1/price` response when
          the composite came from routes that DISAGREED (the
          aggregator's router divergence signal). Omitted when false.
        - `rerouted` — set on a TRIANGULATED `/v1/price` response when
          the composite SUBSTITUTED around a dry configured chain leg
          — the price came via an alternative path, not the documented
          direct chain. Omitted when false.
        - `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 }
        diverged:
          type: boolean
          default: false
          description: >
            Set on a TRIANGULATED /v1/price response when the composite came
            from routes that disagreed (the aggregator's router divergence
            signal). Omitted when false.
        rerouted:
          type: boolean
          default: false
          description: >
            Set on a TRIANGULATED /v1/price response when the composite
            substituted around a dry configured chain leg — the price came via
            an alternative path, not the documented direct chain. Omitted when
            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, "null"]
          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 }
        tvl:
          type: object
          description: |
            Current pooled-liquidity value (TVL) in USD, from a
            background-refreshed snapshot (~10-min cadence) of the
            protocol's absolute pool reserves — Soroswap's current pair
            reserves read from the certified lake (archived pairs
            excluded), Aquarius' latest per-pool post-state reserve
            snapshots, and Phoenix/Comet current pool STORAGE entries
            read from the lake (their events carry flow deltas, not
            post-state reserves, so the figure comes from the curated
            pools' persistent storage; archived pools excluded) —
            valued through the same USD price tiers that stamp
            `trades.usd_volume` (declared peg → direct VWAP → XLM
            bridge). Reserve legs that cannot be priced contribute 0
            and count their pool in `unpriced_pools` — as does a pool
            whose captured storage shape is unrecognised (it is never
            partially decoded or guessed) — so `tvl_usd` is an honest
            LOWER BOUND whenever `unpriced_pools` > 0. Absent for
            protocols without an absolute reserve source (SDEX is an
            order book), and before the first snapshot refresh after
            process start.
          required: [tvl_usd, pools_total, pools_priced, unpriced_pools, as_of, basis]
          properties:
            tvl_usd:
              type: string
              description: Summed USD value of every priced reserve leg (decimal string — ADR-0003).
              example: "1234567.89"
            pools_total:
              type: integer
              description: Pools with a current reserve observation in this snapshot.
            pools_priced:
              type: integer
              description: Pools whose every reserve leg resolved to a USD price.
            unpriced_pools:
              type: integer
              description: Pools with at least one unpriceable reserve leg (their priceable legs still contribute).
            as_of:
              type: string
              format: date-time
              description: When the snapshot was computed.
            basis:
              type: string
              description: One-line provenance of what was measured and how it was valued.

    # SDEX order-book depth (/sdex/orderbook).
    SDEXOrderBook:
      type: object
      required:
        [selling, buying, as_of_ledger, snapshot_at, asks, bids,
         ask_offers, bid_offers, depth]
      properties:
        selling: { type: string, description: "Base asset (what asks sell)." }
        buying: { type: string, description: "Quote asset." }
        as_of_ledger:
          type: integer
          format: int64
          description: Ledger high-water the in-process book has applied.
        snapshot_at:
          type: string
          format: date-time
          description: When the book last advanced.
        asks:
          type: array
          description: Aggregated ask levels, ascending by price.
          items: { $ref: "#/components/schemas/SDEXOrderBookLevel" }
        bids:
          type: array
          description: Aggregated bid levels, descending by price.
          items: { $ref: "#/components/schemas/SDEXOrderBookLevel" }
        ask_offers:
          type: integer
          description: Live ask offers BEFORE the depth cap.
        bid_offers:
          type: integer
          description: Live bid offers BEFORE the depth cap.
        depth:
          type: integer
          description: Applied per-side level cap.

    SDEXOrderBookLevel:
      type: object
      required:
        [price, price_r, base_amount, quote_amount, cum_base_amount,
         cum_quote_amount, offers]
      properties:
        price:
          type: string
          description: Quote-per-base price, the exact rational rendered at 7 decimal places.
        price_r:
          type: object
          description: Exact reduced price fraction (n/d = price).
          required: [n, d]
          properties:
            n: { type: integer, format: int64 }
            d: { type: integer, format: int64 }
        base_amount:
          type: string
          description: Level depth in the base asset (decimal string, whole units).
        quote_amount:
          type: string
          description: Level depth in the quote asset (decimal string, whole units).
        cum_base_amount:
          type: string
          description: Running base-asset total from the top of the book.
        cum_quote_amount:
          type: string
          description: Running quote-asset total from the top of the book.
        offers:
          type: integer
          description: Live offers aggregated into this level.

    # 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]

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

    # /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 }
            p95_target_ms:
              type: number
              description: >-
                p95 latency SLO target in milliseconds. Echoed so callers
                (and the status page) judge against the same threshold the
                `overall` roll-up uses — a breach of either target rolls
                `overall` to "degraded".
            p99_target_ms:
              type: number
              description: p99 latency SLO target in milliseconds. See p95_target_ms.
        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" }
        incidents_status:
          type: string
          enum: [ok, degraded, unknown]
          description: |
            Explicit tri-state trust signal for the `incidents` block.
            The counts alone are ambiguous: a failed Alertmanager query
            zeroes them, which is byte-identical to "no alerts firing",
            so a banner reading `active_count` via a `?? 0` chain would
            publish "0 active alerts" while alerting is blind. Read this
            field first:
              - "ok":       query succeeded, no alerts firing.
              - "degraded": query succeeded, one or more alerts firing
                            (see the counts + `active` list).
              - "unknown":  the alerts query FAILED, or no metrics
                            backend is wired — the counts are NOT
                            trustworthy; render "unknown", not "0 active".
            Always present.
      required: [overall, region, services, incidents_status]

    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:
        kind:         { type: string, enum: [stellar_asset], description: "Wire-shape discriminator for the /v1/assets/{asset_id} oneOf (ADR-0042). Always \"stellar_asset\" on this schema. See `Asset.type` for the separate protocol/class discriminator (native/classic/soroban/fiat/global/external) — `kind` says which SHAPE this payload is, `type` says which Stellar asset CLASS within that shape." }
        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, "null"] }
        contract_id:
          type: [string, "null"]
          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, "null"] }
        decimals:     { type: integer, description: "On-chain smallest-unit scale. 7 for classic + native (stroops, protocol-fixed). For Soroban tokens this is the contract's real decimals() read from its captured instance metadata; 7 when the metadata isn't derivable from the lake." }
        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, "null"] }
        description:       { type: [string, "null"] }
        image:             { type: [string, "null"] }
        org_name:          { type: [string, "null"] }
        anchor_asset:      { type: [string, "null"] }
        anchor_asset_type: { type: [string, "null"] }
        # SEP-1 issuance declarations (issuer's stated commitments,
        # distinct from the live-ledger F2 fields below).
        conditions:        { type: [string, "null"], description: "Issuer's declared terms / conditions text." }
        fixed_number:      { type: [string, "null"], 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, "null"], description: "SEP-1-declared maximum supply ceiling (decimal string). Distinct from `max_supply` below which is operator/policy-derived." }
        is_unlimited:      { type: [boolean, "null"], 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, "null"], description: "Raw integer in asset's smallest unit (per ADR-0011 supply derivation). Null when no snapshot exists." }
        total_supply:       { type: [string, "null"], description: "Raw integer in asset's smallest unit. Null when no snapshot exists." }
        max_supply:         { type: [string, "null"], description: "Raw integer in asset's smallest unit. Null for uncapped issuers without operator override or SEP-1 declaration." }
        price_usd:          { type: [string, "null"], 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. When `price_basis` is present the value is NOT a market observation — see that field." }
        price_basis:        { type: string, enum: [declared_peg], description: "Present ONLY when price_usd is not a market observation. `declared_peg`: the price was filled from an operator-declared 1:1 fiat peg × the current fiat→USD FX rate (pricing_guard.fiat_pegged_classic_assets) because no market-derived price survived the thin-market substance gate. Absent = market-derived (the pre-existing contract, unchanged). Peg-priced rows deliberately carry no change pills, sparkline claim, or market_cap derived from the peg — the fill asserts a conversion basis, not a market." }
        change_24h_pct:     { type: [string, "null"], 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, "null"], description: "circulating_supply × USD price / 10^decimals, two fractional digits. Null when supply or USD price is unavailable, OR when suppressed as dust-liquidity (see market_cap_low_liquidity)." }
        fdv_usd:            { type: [string, "null"], description: "max_supply × USD price / 10^decimals, two fractional digits. Null when max_supply is null, USD price unavailable, OR when suppressed as dust-liquidity (see market_cap_low_liquidity)." }
        market_cap_low_liquidity: { type: boolean, description: "True when market_cap_usd and fdv_usd were deliberately suppressed (served null) because the backing price came from negligible liquidity — a single venue AND trailing-24h USD volume below the server's aggregate.min_market_cap_volume_usd floor. Disambiguates 'suppressed on purpose' from 'no supply/price data'; the price_usd itself still serves — the guard is on the valuation, not the price. Omitted when a cap is present." }
        supply_basis:
          type: [string, "null"]
          enum: [xlm_sdf_reserve_exclusion, xlm_total_only, issuer_exclusion, admin_exclusion, override, sep1_declared_max, sep41_lake_flows, no_metadata]
          description: |
            Which ADR-0011 policy produced the supply numbers.
            Surfaced so consumers can decide how much to trust the
            absolute value: `issuer_exclusion`/`admin_exclusion` are
            algorithm defaults; `override` indicates an operator
            curated the locked-set or max_supply;
            `sep1_declared_max` means max_supply (and fdv_usd) come
            from the issuer's own stellar.toml `[[CURRENCIES]]`
            max_number/fixed_number declaration — self-declared by
            the issuer, not on-chain enforced; `sep41_lake_flows` is
            the lake-derived Σmint−Σburn−Σclawback event-sum served
            for SEP-41 tokens outside the operator watch-list
            (total == circulating; no admin exclusion). Null when no
            supply snapshot is available.
        volume_24h_usd:
          type: [string, "null"]
          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, "null"], description: "Trailing-1h price change, signed decimal percentage." }
        change_7d_pct:      { type: [string, "null"], description: "Trailing-7d price change, signed decimal percentage." }
        first_seen_ledger:  { type: [integer, "null"], format: int64, description: "First ledger this asset was observed trading." }
        last_seen_ledger:   { type: [integer, "null"], format: int64, description: "Most recent ledger this asset was observed trading." }
        observation_count:  { type: [integer, "null"], format: int64, description: "Total observed trade rows for this asset. Emitted as `0` for a catalogued asset with no observations; absent only when the asset has no asset-catalogue row at all (so `0` and \"unknown\" are distinguishable)." }
        markets_count:      { type: [integer, "null"], description: "Distinct (base, quote) pairs over the trailing 24h." }
        trade_count_24h:    { type: [integer, "null"], description: "Trades the asset participated in over the trailing 24h." }
        price_history_24h:
          type: [array, "null"]
          description: "24 hourly USD-price samples (oldest first) for sparkline rendering."
          items:
            type: object
            properties:
              t: { type: string, format: date-time }
              p: { type: [string, "null"] }
            required: [t]
        price_history_7d:
          type: [array, "null"]
          description: "7 daily USD-price samples (oldest first)."
          items:
            type: object
            properties:
              t: { type: string, format: date-time }
              p: { type: [string, "null"] }
            required: [t]
        ath:
          type: [object, "null"]
          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, "null"]
          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, "null"] }
              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." }
        issuer_directory_tags:
          type: array
          items: { type: string }
          description: |
            Curated third-party labels the account_directory table
            (migration 0136; synced from the MIT-licensed
            stellar-expert/public-directory) carries for this asset's
            ISSUER G-address. Tags follow the upstream registry
            (#exchange, #anchor, #issuer, #malicious, #unsafe, …).
            DISPLAY-ONLY, third-party attribution — NEVER an input to
            verification, scam-suppression, or pricing (they do not
            alter `price_usd`, verified status, or any gate). Clients
            should surface `malicious`/`unsafe`/`fraud`/`scam`/`hack`/
            `phishing` as prominent warnings. Omitted when the issuer
            isn't listed.
        issuer_directory_domain: { type: string, description: "Domain the account_directory carries for the issuer (e.g. audrev-stellar.com). Display-only attribution; omitted when unlisted." }
        issuer_directory_name: { type: string, description: "Name the account_directory carries for the issuer. Display-only attribution; omitted when unlisted." }
        volume_character:
          type: string
          enum: [market, operational, concentrated]
          description: |
            Classifies the asset's trailing-window trade volume by account
            structure (wash-and-scam-signals design §2):
              - `market` — honest multi-account market activity (default).
              - `operational` — an issuer-side mint/redeem wrap corridor
                (USDC↔USDCAllow, AUDD↔AUDR); real, but not market activity.
              - `concentrated` — >90% of volume in one account pair on a
                market-styled pair (volume-painting wash, third-party
                ping-pong, dust-bot).
            ANALYTICS / DISPLAY only — it does NOT re-rank anything (that is
            design §4, operator-policy-gated) and does not feed pricing or
            verification. Omitted when the reader isn't wired.
        volume_character_signals:
          type: object
          description: |
            The underlying account-structure signals `volume_character` is
            derived from — and the fields §4's concentration-adjusted ranking
            would sort on. Shares are fractions in [0,1] (4 dp).
          properties:
            window_days:                { type: integer, description: "Trailing window the signals roll over (14)." }
            volume_usd:                 { type: string, description: "Priced (usd_volume non-null) window volume the shares are computed against, as a decimal string." }
            distinct_makers:            { type: integer }
            distinct_takers:            { type: integer }
            top_account_pair_vol_share: { type: number, description: "Max share of window volume in one UNORDERED (maker,taker) account pair." }
            self_cross_share:           { type: number, description: "Share of volume where maker == taker." }
            issuer_side_share:          { type: number, description: "Share of volume where the asset's issuer is maker or taker." }
            market_styled_share:        { type: number, description: "Share of volume against a real price surface (native / USDC / fiat)." }
            is_market_styled:           { type: boolean, description: "market_styled_share >= 0.5." }
          required: [window_days, volume_usd, distinct_makers, distinct_takers, top_account_pair_vol_share, self_cross_share, issuer_side_share, market_styled_share, is_market_styled]
        unverified_warning:
          oneOf:
            - { $ref: "#/components/schemas/UnverifiedWarning" }
            - type: "null"
          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.
        unverified_ticker_collision:
          type: boolean
          default: false
          description: |
            LISTING-row trust signal (/v1/assets): true when this
            row's (code, issuer) uses a verified currency's Stellar
            ticker but is NOT the verified issuer — a look-alike /
            impersonator. The listing serves `COALESCE(slug, code)
            AS slug`, so an impersonator with a NULL slug emits the
            VERIFIED asset's CODE as its slug; a consumer keyed only
            on `slug ∈ verified-set` would badge it "verified". AND
            the verified-slug check with `!unverified_ticker_collision`
            so only the real verified row (which carries this false)
            keeps the badge. The detail path stamps the richer
            `unverified_warning` body instead. Omitted (false) for the
            verified asset and codes no verified currency claims.
      required: [kind, 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…). EMPTY STRING when the ticker matches a well-known asset with NO verified Stellar issuance (USDT, XRP, BTC, …) — there is nothing on Stellar to redirect to; render the warning, do not redirect." }
        verified_name:     { type: string,  description: "Human-readable currency name (e.g. \"USD Coin\")." }
        verified_issuer:   { type: [string, "null"], 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:
        kind:             { type: string, enum: [catalogue], description: "Wire-shape discriminator for the /v1/assets/{asset_id} oneOf (ADR-0042). Always \"catalogue\" on this schema." }
        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, "null"], 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, "null"], format: date-time, description: "Observation timestamp of the served price." }
        circulating_supply: { type: [string, "null"], 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, "null"], 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)." }
        market_cap_low_liquidity: { type: boolean, description: "True when market_cap_usd was suppressed because the backing price came from negligible liquidity. Present for wire parity with the per-Stellar-asset Asset surface; the fiat market cap (M2 × deep FX rate) is never dust-gated, so this stays false on catalogue rows today. Omitted when false." }
      required: [kind, 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, "null"]
          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, "null"] }
        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, "null"] }
        description:     { type: [string, "null"] }
        image:           { type: [string, "null"], description: "Issuer-supplied logo URL. http(s) only — non-http schemes are stripped at overlay time." }
        org_name:        { type: [string, "null"] }
        anchor_asset:    { type: [string, "null"] }
        anchor_asset_type: { type: [string, "null"], 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, "null"], description: "Issuer's declared terms / conditions text." }
        fixed_number:    { type: [string, "null"], 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, "null"], 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, "null"], 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)."
        as_of_ledger: { type: integer, format: int64, description: "Lake watermark this read is fresh to (ADR-0041): the highest ledger captured at serve time (for native, the exact ledger total_coins came from). Omitted when no watermark reader is wired. Pairs with flags.stale." }
      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, "null"], description: "Window size for vwap/twap; omitted for last_trade." }
        change_24h_pct:
          type: [string, "null"]
          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, "null"], format: float, minimum: 0, maximum: 1, description: "Multi-factor confidence score in [0, 1] (ADR-0019)." }
        confidence_factors:
          type: [object, "null"]
          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 }
            cross_oracle_checked:
              type: boolean
              description: >-
                True only when real cross-oracle reference data fed
                the cross_oracle factor; false means the neutral
                no-data value was used. False MUST be read as "could
                not verify", never as "references agree" (same
                discipline as flags.divergence_checked).
            cross_oracle_agreement:
              type: integer
              description: >-
                Number of independent external references (Reflector
                DEX/CEX/FX, Redstone, Band, CoinGecko, Chainlink)
                that corroborated this price within the divergence
                threshold at the last refresh (ADR-0019 Phase 3
                cross-oracle agreement). Always 0 when
                cross_oracle_checked is false.
            liquidity_measured:
              type: boolean
              description: >-
                True only when a real USD volume fed the liquidity
                factor. False means the neutral no-information value
                was substituted because the pair could not be valued
                in USD — do NOT read false as "liquidity is
                mid-range"; the neutral sits inside the measured
                curve's range.
            triangulation_agreement:
              type: number
              description: >-
                Agreement between this pair's direct price and the
                composite implied by its configured triangulation
                chain (ADR-0019 amendment 2026-07-25). Same shape as
                cross_oracle - 1.0 within tolerance, decaying with
                divergence, 0.7 as the no-data neutral. Disagreement
                is a manipulation signal; agreement corroborates at
                half the weight of an independent reference.
            triangulation_checked:
              type: boolean
              description: >-
                True only when a fresh composite existed to compare
                against. False means no chain is configured or the
                composite was stale - NOT "the composite agrees". The
                factor carries zero weight in the confidence score
                when unchecked, so unchecked pairs score exactly as
                they did before this factor existed.
      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]

    PriceChangeHorizon:
      # One trailing-window delta on /v1/price/changes. Every field
      # except `available` is null when the pair has no closed bucket
      # that far back — the miss is per-horizon, never a whole-call
      # error. Mirrors PriceChangeHorizon in internal/api/v1/price_changes.go.
      type: object
      properties:
        change_pct:
          type: [string, "null"]
          description: >-
            Signed percentage move of the current price vs the reference
            price, two fractional digits with an explicit leading "+" on
            gains (e.g. "+3.62", "-1.04", "0.00"). Null when unavailable.
        reference_price:
          type: [string, "null"]
          description: "Closed VWAP at-or-before now−horizon (decimal string). Null when unavailable."
        reference_at:
          type: [string, "null"]
          format: date-time
          description: "Close time of the reference bucket (RFC 3339) — never the exact horizon instant. Null when unavailable."
        resolution:
          type: [string, "null"]
          description: "CAGG resolution that served the reference bucket — one of 1m, 15m, 1h, 4h, 1d. Null when unavailable."
        available:
          type: boolean
          description: "False when no closed bucket exists that far back (all sibling fields null)."
      required: [change_pct, reference_price, reference_at, resolution, available]

    PriceChanges:
      # The /v1/price/changes payload — EXACTLY the fields the handler's
      # PriceChanges serves (internal/api/v1/price_changes.go).
      # Contract-tested against the SDK in pkg/client/spec_contract_test.go.
      type: object
      properties:
        asset_id:           { type: string }
        quote:              { type: string }
        current_price:      { type: string, description: "Current closed VWAP (decimal string; never a JSON number)." }
        current_price_type: { type: string, enum: [vwap], description: "Always vwap — the anchor is a closed-bucket VWAP." }
        observed_at:        { type: string, format: date-time, description: "Close time of the current-price bucket." }
        resolution:
          type: string
          enum: [1m, 15m, 1h, 4h, 1d, 1w, 1mo]
          description: "CAGG resolution that served the current-price bucket."
        "1h":  { $ref: "#/components/schemas/PriceChangeHorizon" }
        "24h": { $ref: "#/components/schemas/PriceChangeHorizon" }
        "7d":  { $ref: "#/components/schemas/PriceChangeHorizon" }
        "30d": { $ref: "#/components/schemas/PriceChangeHorizon" }
      required: [asset_id, quote, current_price, current_price_type, observed_at, resolution, 1h, 24h, 7d, 30d]

    PriceChangesEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/PriceChanges" }
          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, "null"] }
      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, twap, market_cap]
                  description: |
                    `vwap` for the default price chart; `twap` for the
                    time-weighted chart variant (chart.go
                    handleChartTWAP, twap_1h / twap_1d CAGGs);
                    `market_cap` on the market-cap chart variant
                    (chart.go handleChartMarketCap).
                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." }
        base_decimals:
          type: integer
          description: |
            Smallest-unit scale for `base_amount`: divide by
            10^base_decimals for whole-asset units. 7 for
            native/classic/fiat; the token contract's declared
            `decimals()` for Soroban tokens. Present on /v1/history
            (resolved once per pair per request); omitted on
            /v1/observations, whose rows carry no per-side scale.
        quote_decimals:
          type: integer
          description: |
            Smallest-unit scale for `quote_amount` — see
            `base_decimals`.
        routed_via:
          type: string
          description: |
            Router / aggregator whose same-transaction invocation
            drove this trade (`routers.name`, e.g.
            `soroswap-router`; see /v1/aggregators). Omitted for
            direct trades — and for very recent routed trades the
            attribution sweeper (1-minute cadence) hasn't tagged
            yet.
            Descriptive metadata, first-wins (never re-tagged), derived
            from the trade's own same-transaction routing structure — NOT
            an authenticated fact: a trader can structure their own
            transaction to carry a chosen router, so `routed_via` on
            individual trades is not tamper-proof provenance. It does not
            affect price/VWAP (routers are IncludeInVWAP=false and write
            only to their own `_swaps` surface).
      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]

    # Routers / aggregator-vault registry (/aggregators)
    AggregatorRow:
      type: object
      properties:
        contract_id: { type: string, description: "C-strkey of the router / vault contract." }
        name:        { type: string, description: "Registry name — the value trades carry in routed_via (routers.name)." }
        kind:
          type: string
          enum: [router, aggregator-vault]
          description: |
            `router` routes per-tx into underlying venues (Soroswap
            router); `aggregator-vault` holds persistent capital
            (DeFindex). Only routers accrue routed_via trade tags.
        protocol:        { type: string, description: "Protocol slug the contract belongs to (joins /v1/protocols)." }
        auto_discovered: { type: boolean, description: "True when the discovery heuristic surfaced this entry; false for operator-vetted manual seeds." }
        routed_trades_24h:
          type: integer
          description: Count of trades tagged routed_via=name in the trailing 24 h. Always 0 for vault-kind entries.
        routed_volume_24h_usd:
          type: [string, "null"]
          description: |
            SUM of the routed trades' USD volume, decimal string.
            Null when no routed trade in the window carried a USD
            valuation (usd_volume is aggregator-backfilled and can
            lag) — distinct from a zero-trade router, which reports
            `routed_trades_24h: 0`.
        last_routed_at:
          type: [string, "null"]
          format: date-time
          description: ts of the most recent routed trade in the window; null when none.
        notes:
          type: array
          items: { type: string }
          description: |
            Honest coverage caveats for THIS row's stats, present for
            `kind: router` rows only (omitted — not an empty array —
            when a row has none, i.e. every `aggregator-vault` row
            today). Two cases: every router row's count conflates
            direct calls, calls wrapped by an unregistered contract,
            and legacy rows recorded before call-path tracking
            (migration 0101, 2026-07-10) — those three are
            indistinguishable today; `auto_discovered: true` rows
            (evidence-observed wrapper contracts, not vendor- or
            WASM-audit-verified — see the registry seed migration)
            additionally only count trades whose router call carried
            call_path data, so a low or zero count there means
            "not yet attributed", not "little volume".
      required: [contract_id, name, kind, protocol, auto_discovered, routed_trades_24h, routed_volume_24h_usd, last_routed_at]

    AggregatorsEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/AggregatorRow" }
          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 the window hit the per-request trade cap; the bar reflects only the chronologically LAST N trades (the reader drops the oldest rows under the limit), so open/high/low may not be the true window values." }
      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, "null"]
          description: "Trailing-24h USD volume summed from prices_1m. Decimal string. Null when no USD-equivalent trades."
        last_price:
          type: [string, "null"]
          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, "null"]
          format: date-time
          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, "null"], description: "Decimal string. Null when no USD-equivalent trades." }
        last_price:      { type: [string, "null"], description: "Most recent quote-per-base price observed on THIS venue. Decimal string." }
      required: [source, base, quote, last_trade_at, trade_count_24h]

    # /v1/pools/reserves row — one AMM pool CONTRACT's current reserve
    # state + model-derived depth. Mirrors internal/api/v1/
    # pools_reserves.go `PoolReservesRow`. Soroswap-only today (the
    # honest-coverage contract documented on the path).
    PoolReservesRow:
      type: object
      properties:
        pool:         { type: string, description: "Pool (pair) contract C-strkey." }
        source:       { type: string, description: "Venue. `soroswap` is the only venue served today." }
        model:        { type: string, description: "AMM model the depth table assumes: `constant_product` (x·y=k, fee on input). The depth is a model-derived estimate, not an order book." }
        fee_bps:      { type: integer, description: "Venue swap fee in basis points (Soroswap: 30)." }
        as_of_ledger: { type: integer, description: "Ledger of the pool's last state change; reserves are current as of this ledger and unchanged since." }
        token0:       { $ref: "#/components/schemas/PoolReserveToken" }
        token1:       { $ref: "#/components/schemas/PoolReserveToken" }
        mid_price_0_in_1: { type: [string, "null"], description: "Decimals-adjusted mid price, token1 per token0. Decimal string; null when either side is empty, or when token decimals metadata was unavailable for this request (a mid price is never computed from assumed decimals)." }
        mid_price_1_in_0: { type: [string, "null"], description: "Inverse mid price. Null under the same conditions as mid_price_0_in_1." }
        depth:
          type: array
          description: "Depth estimate per slippage tier (0.5% / 1% / 2%). Empty when either reserve is zero."
          items: { $ref: "#/components/schemas/PoolDepthLevel" }
      required: [pool, source, model, fee_bps, as_of_ledger, token0, token1, mid_price_0_in_1, mid_price_1_in_0, depth]

    PoolReserveToken:
      type: object
      properties:
        contract: { type: string, description: "Token contract C-strkey." }
        symbol:   { type: string, description: "Best-effort display symbol from the token's on-chain METADATA — SELF-DECLARED by the token contract, not a verified identity. Omitted when the token declares none." }
        decimals: { type: integer, description: "Token's on-chain decimals declaration; 7 (the SAC default) when no readable declaration is captured." }
        reserve:  { type: string, description: "Pool reserve, exact i128 base-unit decimal string (ADR-0003)." }
      required: [contract, decimals, reserve]

    PoolDepthLevel:
      type: object
      properties:
        slippage_pct: { type: string, description: "Tier, percent (e.g. `0.5`)." }
        token0_in:    { $ref: "#/components/schemas/PoolDepthSide" }
        token1_in:    { $ref: "#/components/schemas/PoolDepthSide" }
      required: [slippage_pct, token0_in, token1_in]

    PoolDepthSide:
      type: object
      description: "One direction at a tier: the largest input whose AVERAGE execution price stays within the tier of mid, and its output. Base-unit decimal strings of the respective tokens."
      properties:
        max_input: { type: string }
        output:    { type: string }
      required: [max_input, output]

    LiquidityPoolReservesRow:
      type: object
      description: "One native (CAP-38) constant-product liquidity pool's current two-sided reserve state + depth (ADR-0039)."
      properties:
        pool:         { type: string, description: "Pool id, L-strkey (SEP-23)." }
        pool_hex:     { type: string, description: "Pool id as 32-byte hex (Horizon/SDEX form)." }
        model:        { type: string, description: "AMM model the depth table assumes: `constant_product` (x·y=k, fee on input). Model-derived estimate, not an order book." }
        fee_bps:      { type: integer, description: "Pool's on-chain swap fee in basis points (30 for every constant-product pool today)." }
        as_of_ledger: { type: integer, description: "Ledger of the pool's last state change; reserves are current as of this ledger and unchanged since." }
        trustlines:   { type: integer, description: "Number of pool-share trustlines (distinct liquidity providers). The listing rank key." }
        total_shares: { type: string, description: "Total issued pool-share supply, base-unit decimal string." }
        reserve_a:    { $ref: "#/components/schemas/LiquidityPoolReserveSide" }
        reserve_b:    { $ref: "#/components/schemas/LiquidityPoolReserveSide" }
        mid_price_a_in_b: { type: [string, "null"], description: "Decimals-adjusted mid price, asset B per asset A. Decimal string; null when either side is empty." }
        mid_price_b_in_a: { type: [string, "null"], description: "Inverse mid price. Null when either side is empty." }
        depth:
          type: array
          description: "Depth estimate per slippage tier (0.5% / 1% / 2%). Empty when either reserve is zero."
          items: { $ref: "#/components/schemas/LiquidityPoolDepthLevel" }
      required: [pool, pool_hex, model, fee_bps, as_of_ledger, trustlines, total_shares, reserve_a, reserve_b, mid_price_a_in_b, mid_price_b_in_a, depth]

    LiquidityPoolReserveSide:
      type: object
      description: "One side of a native pool: the classic asset's canonical id + its reserve."
      properties:
        asset:    { type: string, description: "Canonical asset_id (`native` or `CODE-ISSUER`)." }
        decimals: { type: integer, description: "Asset decimals — always 7 for classic Stellar assets." }
        reserve:  { type: string, description: "Pool reserve, exact base-unit (stroop) decimal string (ADR-0003)." }
      required: [asset, decimals, reserve]

    LiquidityPoolDepthLevel:
      type: object
      properties:
        slippage_pct: { type: string, description: "Tier, percent (e.g. `0.5`)." }
        asset_a_in:   { $ref: "#/components/schemas/PoolDepthSide" }
        asset_b_in:   { $ref: "#/components/schemas/PoolDepthSide" }
      required: [slippage_pct, asset_a_in, asset_b_in]

    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." }
              trade_count: { type: integer, format: int64, description: "Number of trades in the hour (0 on zero-filled buckets). Powers the trade-count line above the $-volume bars on source charts." }
            required: [hour, volume_usd, trade_count]
        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." }
              trade_count: { type: integer, format: int64, description: "Number of trades in the hour (0 on zero-filled buckets)." }
            required: [hour, volume_usd, trade_count]
      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 }
        endpoint:
          type: string
          description: |
            Endpoint family — the route PATTERN the requests
            matched (e.g. `/v1/assets/{asset_id}`), never a raw
            URL. Omitted on the legacy fallback shape (one row per
            day, pre-rollup deployments).
        requests:
          type: integer
          description: |
            Allowed requests (all responses except 429). Throttled
            calls are reported separately and never eat quota.

            On the legacy fallback shape (`endpoint` omitted) this
            column is the BILLABLE counter the monthly quota is
            enforced against, so it also excludes platform-caused
            5xx responses: an outage on our side must not consume
            the quota you paid for. Those responses are still
            reported under `errors`.
        errors:
          type: integer
          description: 4xx (excluding 429) + 5xx responses.
        throttled:
          type: integer
          description: >
            429 rejections (rate-limit and monthly-quota). Reported
            under the `unmatched` endpoint, not the caller's target
            route — the rejection happens before the router resolves
            a pattern.

    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 }
                scopes:
                  type: array
                  items: { type: string }
                  description: |
                    Capability scopes the key was minted with.
                    Absent/empty = full access (the pre-scopes
                    posture).
              required: [key_id, plaintext, label]
          required: [data]

    AdminAccountView:
      type: object
      description: |
        Operator projection of a platform account — the tier + override
        knobs a staff member inspects / edits. No customer PII beyond
        the billing email.
      properties:
        id:              { type: string, format: uuid }
        name:            { type: string }
        slug:            { type: string }
        tier:
          type: string
          enum: [free, partner]
          description: |
            Canonical three-level model (anon is not an account
            tier). Legacy paid-tier strings in stored rows are
            folded to these on read: starter→free;
            pro/business/enterprise→partner.
        status:
          type: string
          enum: [active, suspended, closed]
        billing_email:   { type: string }
        created_at:      { type: string, format: date-time }
        suspended_reason: { type: string }
        rate_limit_per_min_override:
          type: integer
          description: 0 = inherit the tier default; positive = account-wide per-key floor.
        monthly_request_quota_override:
          type: integer
          format: int64
          description: 0 = inherit the tier default; positive = metered cap applied when a key has no per-key quota.
      required: [id, name, slug, tier, status, rate_limit_per_min_override, monthly_request_quota_override]

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

    AdminLookupResponse:
      type: object
      description: |
        Staff customer look-up result (`GET /v1/account/admin/lookup`):
        the resolved account plus the users on it. NOT enveloped — a bare
        `{account, users}` object, served with `Cache-Control: no-store`
        (staff-only customer PII).

        `account` reuses the operator `AdminAccountView` projection. Note
        one behavioural difference from `GET /v1/admin/accounts/{id}`:
        this surface omits `rate_limit_per_min_override` /
        `monthly_request_quota_override` when they are 0 (inherit), where
        the operator surface always emits them.
      properties:
        account: { $ref: "#/components/schemas/AdminAccountView" }
        users:
          type: array
          items: { $ref: "#/components/schemas/AdminUserView" }
      required: [account, users]

    AdminUserView:
      type: object
      description: |
        One user under the looked-up account (staff customer look-up).
        Staff-only customer PII.
      properties:
        id:             { type: string, format: uuid }
        email:          { type: string, format: email }
        display_name:   { type: string }
        role:           { type: string }
        is_staff:       { type: boolean }
        email_verified: { type: boolean }
        last_login_at:  { type: string, format: date-time }
      required: [id, email, role, is_staff, email_verified]

    StatusNotice:
      type: object
      description: |
        An operator-posted customer-facing status banner (admin Phase
        1.5). Distinct from the Alertmanager-derived incidents on
        /v1/status and the embedded post-mortems on /v1/incidents.
      properties:
        id:       { type: string, format: uuid }
        title:    { type: string }
        body:     { type: string }
        severity:
          type: string
          enum: [maintenance, minor, major, critical]
        status:
          type: string
          enum: [active, resolved]
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }
        resolved_at: { type: string, format: date-time }
      required: [id, title, body, severity, status]

    StatusNoticesList:
      type: object
      properties:
        notices:
          type: array
          items: { $ref: "#/components/schemas/StatusNotice" }
        count: { type: integer }
      required: [notices, count]

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

    StatusNoticeEnvelope:
      allOf:
        - { $ref: "#/components/schemas/EnvelopeMeta" }
        - type: object
          properties:
            data: { $ref: "#/components/schemas/StatusNotice" }
          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]
