openapi: 3.1.0
info:
  title: Search Router — client API
  version: "2026-10-23"
  summary: The sr-key client surface of the Search Router platform.
  description: >
    The machine-readable authority for the Search Router **client** surface: the
    search data plane (`router-api.nyks.dev`) and the public agent-onboarding
    routes (`agents-v1.nyks.dev`). Every endpoint here is authenticated by a
    tenant/agent `sr-` bearer key (registration is the one exception — it mints
    one). The prose companion `search-api-reference.md` carries the semantics
    OpenAPI cannot express well (NDJSON streaming choreography, session
    behaviour, the credit/billing model, architecture, and the frozen-vs-moving
    map) and is the human authority; this file and it are kept in lockstep.

    Portal/console and billing routes (under `/internal/v1/*`, `platform.nyks.dev`)
    are NOT client-authable with an `sr-` key and are intentionally out of scope
    here; see the reference §9 for the economics surface and the recommended
    key-authed balance endpoint.

    Every field carries `x-stability`: `frozen` (safe to depend on) or `moving`
    (may change as the student router and paid tiers land — reference §8).
  x-envelopes:
    gateway: 'Flat: {"error": string, "code": string} on EVERY refusal, the guard''s and the listener''s included (router-api.nyks.dev).'
    control-plane: 'Nested + mirror: {"error": {"code","message"}, "code": string} (agents-v1.nyks.dev / platform.nyks.dev). `code` is top-level on BOTH hosts; only the `error` field''s type differs.'

servers:
  - url: https://router-api.nyks.dev
    description: Search data plane (search, fetch, traces, feedback, judge, limits, turns, account; the public /healthz).
  - url: https://agents-v1.nyks.dev
    description: Public agent onboarding (register, me, poll, claim-code, enroll).

security:
  - srKey: []

tags:
  - name: search
    description: The fan-out search API.
  - name: traces
    description: Read back and grade your own requests.
  - name: limits
    description: Read and set your own daily/weekly/monthly spend caps.
  - name: account
    description: What the gateway knows about the calling key and tenant.
  - name: health
    description: The public readiness probe.
  - name: agents
    description: Headless onboarding — an agent mints its own key, a human claims it.

paths:
  /v1/search:
    post:
      tags: [search]
      operationId: search
      x-scope: search
      summary: One query, fanned out to every configured provider, ranked.
      description: >
        Returns when every fast-lane arm has answered or the shared deadline
        fires. Slow-lane research arms keep running and land on the trace later
        (`usage.pending`). Always HTTP 200 on a well-formed request even if every
        provider failed — the per-candidate `status` carries the detail.
      servers:
        - url: https://router-api.nyks.dev
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SearchRequest' }
      responses:
        '200':
          description: The ranked fan-out. `X-Request-Id` header == `request_id` == trace id.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
            X-RateLimit-Limit-Hour: { $ref: '#/components/headers/XRateLimitLimitHour' }
            X-RateLimit-Remaining-Hour: { $ref: '#/components/headers/XRateLimitRemainingHour' }
            X-RateLimit-Reset-Hour: { $ref: '#/components/headers/XRateLimitResetHour' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchResponse' }
        '400': { $ref: '#/components/responses/GatewayError' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '402': { $ref: '#/components/responses/GatewayError' }
        '409':
          description: '`idempotency_in_progress` — the first request under this `Idempotency-Key` is still running; `Retry-After: 1`, then send the same request again.'
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '422':
          description: '`idempotency_key_reused` — this `Idempotency-Key` was used before with a different body. Final.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '403': { $ref: '#/components/responses/ScopeError' }
        '429': { $ref: '#/components/responses/GatewayRateLimited' }
        '503': { $ref: '#/components/responses/GatewayUnavailable' }

  /v1/search/stream:
    post:
      tags: [search]
      operationId: searchStream
      x-scope: search
      summary: The same fan-out, streamed as NDJSON in completion order.
      description: >
        Identical fan-out to `/v1/search`; only the reply shape differs. The
        response is `application/x-ndjson`: one JSON event per line, flushed as it
        happens. Status 200 and `X-Request-Id` go out before any provider is
        called, so a mid-stream problem cannot change the HTTP status; request
        rejections (bad tier/query/budget) happen BEFORE the stream and return a
        normal 4xx JSON body instead. Event order:
        `start` → (`agent` | `candidate`)* → `done` → (`candidate` late)* → `complete`.
        `done` carries the byte-identical `/v1/search` response and is the terminal
        event when there is no slow lane; when there is, `complete` is the last
        line. See reference §4 for the full choreography. The `error` event is
        defined but not currently emitted.
      servers:
        - url: https://router-api.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SearchRequest' }
      responses:
        '200':
          description: NDJSON event stream (one JSON object per line). `Idempotency-Key` is ignored on this path.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
            X-RateLimit-Limit-Hour: { $ref: '#/components/headers/XRateLimitLimitHour' }
            X-RateLimit-Remaining-Hour: { $ref: '#/components/headers/XRateLimitRemainingHour' }
            X-RateLimit-Reset-Hour: { $ref: '#/components/headers/XRateLimitResetHour' }
          content:
            application/x-ndjson:
              schema: { $ref: '#/components/schemas/StreamEvent' }
        '400': { $ref: '#/components/responses/GatewayError' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '402': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
        '429': { $ref: '#/components/responses/GatewayRateLimited' }
        '503': { $ref: '#/components/responses/GatewayUnavailable' }

  /v1/traces:
    get:
      tags: [traces]
      operationId: listTraces
      x-scope: read
      x-spend-free: true
      summary: Your requests, newest first (tenant-scoped), one page at a time.
      description: >
        Summaries only — no response document; read one trace for that.
        Newest first by trace id (a UUIDv7, so by the instant the request was
        made). Keyset-paged: `next_before` in the response is the `before` of
        the next page, and is empty on the last. `since`/`until` bound the page
        by request time (closed below, open above). A cursor or bound that
        cannot be read is 400, never silently ignored.
        Spend-exempt (a 402'd tenant can still read its logs); rate/concurrency
        limits still apply.
      servers:
        - url: https://router-api.nyks.dev
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
          description: Page size. Above 200 is 200; unreadable is 50.
        - name: before
          in: query
          required: false
          schema: { type: string }
          description: >
            Cursor: the `next_before` of the previous page (a trace id), or an
            RFC 3339 instant — the page then holds requests made before it.
        - name: since
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: Only requests made at or after this RFC 3339 instant.
        - name: until
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: Only requests made before this RFC 3339 instant.
      responses:
        '200':
          description: A page of trace summaries, the cursor for the next, and the retention that answers.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TraceList' }
        '400':
          description: '`before`, `since` or `until` is not a cursor/instant this listing can read.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
        '404':
          description: Trace retention is disabled on this deployment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }
        '429': { $ref: '#/components/responses/GatewayRateLimited' }

  /v1/traces/{id}:
    get:
      tags: [traces]
      operationId: getTrace
      x-scope: read
      x-spend-free: true
      summary: One stored trace — the exact response document, plus live thinking.
      description: >
        Returns the full response the search produced, plus `stored_at` /
        `expires_at`, and — while a slow arm is still out (`summary.pending > 0`) —
        a `live_thinking` map of what each arm is doing right now. Amended in place
        when the arm lands. Another tenant's trace answers 404, like one that never
        existed. Retention ~1h in memory in front of 30 days durable.
      servers:
        - url: https://router-api.nyks.dev
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: The request_id / trace id.
      responses:
        '200':
          description: The stored trace.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TraceDetail' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
        '404':
          description: Unknown, expired, or another tenant's trace — one answer for all.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }

  /v1/feedback:
    post:
      tags: [traces]
      operationId: feedback
      x-scope: search
      summary: Attach implicit feedback labels to a trace (fire-and-forget).
      description: >
        Free training labels — clicked/cited/rejected and session outcomes.
        Unknown event types are rejected, not stored. Ack is 202. trace_id must
        be one of the caller's own traces in the gateway's retention window;
        an unknown, expired, or another tenant's id is 404 and nothing is recorded.
      servers:
        - url: https://router-api.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FeedbackRequest' }
      responses:
        '202':
          description: Accepted; exported asynchronously.
        '400':
          description: '`malformed_json`, or `invalid_request` for a missing trace_id, no events, too many events, an unknown type or a result event without a url.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
        '404':
          description: '`trace_not_found` — no such trace for this tenant (unknown, expired, or another tenant''s — indistinguishable).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }

  /v1/fetch:
    post:
      tags: [search]
      operationId: fetch
      x-scope: search
      summary: The full text of up to five pages from your own recent search results.
      description: >
        Each page is named by a merged view's `source_id` or by a URL a search
        returned, and is fetchable only if one of this account's own search
        responses returned it in the last 24 hours (else `not_recent`). Returns
        at most `max_chars` characters per page from `offset`, cut on a paragraph
        or line break, with `next_offset` to read on. The upstream returns at most
        a deployment-set number of characters of a page (200,000 on beta; reference
        §5.1); a longer page says so with `truncated` and `full_length`. Link
        markup is removed from the text. Claimed, content-retaining
        accounts only (`403 fetch_unavailable`). Charged per `ok` page (2,000 µ$
        as deployed); every other status is free. Not a general fetcher: a URL no
        search of yours returned is `not_recent`, never fetched. See reference
        §5.1.
      x-stability: moving
      servers:
        - url: https://router-api.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FetchRequest' }
      responses:
        '200':
          description: One entry per requested page, in request order, each with its own `status`. 200 whatever the pages' statuses — an upstream that times out or fails as a whole fails the pages it was asked for (`reason` `upstream_failed`) and leaves the cached ones served, so there is no 502.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FetchResponse' }
        '400':
          description: '`malformed_json`, or `invalid_request` — no pages or more than five, `max_chars` outside 1,000–50,000, a negative `offset`, a `query` over 1,000 characters, or two entries naming the same page. A single malformed page is not a 400: it is that page''s `invalid` status.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '401': { $ref: '#/components/responses/GatewayError' }
        '402': { $ref: '#/components/responses/GatewayError' }
        '403':
          description: '`scope`, or `fetch_unavailable` — an unclaimed agent account, a zero-retention account, or one with no credential fetch can be billed to.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '429': { $ref: '#/components/responses/GatewayRateLimited' }
        '503':
          description: '`fetch_disabled` — fetch is not enabled on this deployment; or `paused` / `overloaded`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }

  /v1/turns:
    post:
      tags: [traces]
      operationId: turnEnd
      x-scope: search
      x-spend-free: true
      summary: Report that a turn of an agent session ended, optionally with its conversation.
      description: >
        A search's `trace` envelope carries what the assistant wrote before it
        searched; this is the other end — how the turn ended, the assistant's
        final message, and optionally the user's message, the model, token
        counts and the turn's steps. Joined to the session's searches by
        `session_id` and `turn_id`. Kept only if the tenant has a retained
        search under that `session_id`. Spends nothing and is exempt from the
        credit and spend-ceiling refusals; it is counted against a rate of its
        own (the tenant's request limits, never below 30 a minute and 120 an
        hour), not the search rate. Fields this build does not know are
        ignored. Body <= 512 KiB. See reference §7.1.
      x-stability: moving
      servers:
        - url: https://router-api.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TurnEndRequest' }
      responses:
        '202':
          description: Accepted. `stored` says whether it will be there to read.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
            X-RateLimit-Limit-Hour: { $ref: '#/components/headers/XRateLimitLimitHour' }
            X-RateLimit-Remaining-Hour: { $ref: '#/components/headers/XRateLimitRemainingHour' }
            X-RateLimit-Reset-Hour: { $ref: '#/components/headers/XRateLimitResetHour' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TurnEndAccepted' }
        '400':
          description: '`malformed_json`, or `invalid_request` for a missing or badly shaped id, an unknown status, or a field over its bound.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
        '413':
          description: '`body_too_large` — the body is larger than 512 KiB.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }
        '429':
          description: '`rate_limited` (the turn-end rate, with Retry-After) or `concurrency`.'
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }
        '503':
          description: '`paused` — the platform is paused; writes are refused with searches.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FlatError' }

  /v1/judge:
    post:
      tags: [traces]
      operationId: judge
      x-scope: search
      summary: "Experimental: grade one of your stored traces with an LLM judge. Not calibrated yet; full support will follow."
      description: >
        Experimental: the judge is not calibrated against human labels yet, so treat its grades
        as indicative. Full support will follow.

        Candidates blinded and shuffled, UMBRELA 0–3 per result plus a listwise
        ranking. Requires >= 2 usable candidates and retained content. An LLM call
        — expect 10–30 s; billed to the platform's own key. 503 with a reason when
        no judge is configured.
      servers:
        - url: https://router-api.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [trace_id]
              properties:
                trace_id: { type: string }
      responses:
        '200':
          description: The judge verdict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JudgeResponse' }
        '400':
          description: '`invalid_request` (missing trace_id) or `trace_no_content` (a metadata-only, zero-retention trace).'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403':
          description: '`scope` (the key''s scopes do not cover this route — the message names the missing scope) or `not_claimed` (an unclaimed agent cannot judge).'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '404':
          description: '`trace_not_found` — no such trace for this tenant.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '502':
          description: '`judge_failed` — the judge model could not grade the trace.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '503':
          description: '`judge_disabled` — judging not configured on this deployment; or `paused`.'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }

  /v1/limits:
    get:
      tags: [limits]
      operationId: getLimits
      x-scope: read
      x-spend-free: true
      summary: Read this project's spend caps and its spend today, this week and this month against them.
      description: >
        The project's own daily/weekly/monthly spend caps (micro-dollars; 0 = uncapped)
        plus today's spend, the effective daily ceiling the gateway is enforcing
        (min of credit balance and the caps), and remaining today. Spends nothing and
        is exempt from the credit/ceiling 402s, so it works even when capped or drained.
      servers:
        - url: https://router-api.nyks.dev
      responses:
        '200':
          description: Current caps and today's spend.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LimitsResponse' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
    post:
      tags: [limits]
      operationId: setLimits
      x-scope: limits
      x-spend-free: true
      summary: Set this project's daily/weekly/monthly spend caps.
      description: >
        Partial update — send only the windows you want to change; an omitted field is
        left as-is, a 0 clears that cap. Windows are calendar UTC (day 00:00, ISO week
        Monday, month 1st). A new cap binds on the next control-plane sweep (seconds).
        Needs the `limits` scope: a key with no scopes may set the caps, a scoped key
        only when `limits` is among its scopes (reference §6.2).
      servers:
        - url: https://router-api.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                daily_micros: { type: integer, format: int64, minimum: 0, description: 'Max spend per UTC day; 0 = uncapped.' }
                weekly_micros: { type: integer, format: int64, minimum: 0, description: 'Max per ISO week (Mon–Sun, UTC); 0 = uncapped.' }
                monthly_micros: { type: integer, format: int64, minimum: 0, description: 'Max per calendar month (UTC); 0 = uncapped.' }
      responses:
        '200':
          description: The updated caps and today's spend (same shape as GET).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LimitsResponse' }
        '400':
          description: Malformed body or a negative amount (code invalid_request).
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }
        '401': { $ref: '#/components/responses/GatewayError' }
        '403': { $ref: '#/components/responses/ScopeError' }
        '502':
          description: Saved, but the gateway could not reload the caps (code reload_failed) — rare; retry.
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }

  /v1/account:
    get:
      tags: [account]
      operationId: getAccount
      x-scope: read
      x-spend-free: true
      summary: What the gateway knows about the calling key and its tenant.
      description: >
        One read: the key (id, label, scopes, expiry),
        the rate windows as they stand (the same numbers as the X-RateLimit-*
        headers), today's spend against the enforced daily ceiling, the
        self-set caps, the retention posture, the tiers the key may request and
        the console URL. The credit balance is NOT here — the gateway does not
        hold it; `console_url` is where it lives. Spends nothing and is exempt
        from the credit/ceiling 402s; counted against the request rate.
        Reference §6.3.
      x-stability: frozen
      servers:
        - url: https://router-api.nyks.dev
      responses:
        '200':
          description: 'The account view. `Cache-Control: no-store`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountResponse' }
        '401': { $ref: '#/components/responses/GatewayError' }
        '429': { $ref: '#/components/responses/GatewayRateLimited' }
        '503': { $ref: '#/components/responses/GatewayUnavailable' }

  /healthz:
    get:
      tags: [health]
      operationId: healthz
      summary: Public readiness probe.
      description: >
        No key. `{"ok":true}` when the gateway is up and serving searches;
        `503 not_ready` while it starts or drains. Carries nothing else — no
        version, no provider, no tenant — and is counted against nothing.
        Reference §6.5.
      x-stability: frozen
      security: []
      servers:
        - url: https://router-api.nyks.dev
      responses:
        '200':
          description: Ready.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HealthResponse' }
        '503':
          description: '`not_ready` (starting or draining) or `overloaded` (the listener''s in-flight limit).'
          content: { application/json: { schema: { $ref: '#/components/schemas/FlatError' } } }

  /v1/agents/register:
    post:
      tags: [agents]
      operationId: agentRegister
      summary: An agent mints its own key on a small unclaimed tier — no credential.
      description: >
        The one route with no auth. Returns the `sr-` key (shown once), a human
        claim URL + code, and a poll URL. The unclaimed key is fast-tier only,
        $0.10/day, 10 rpm, concurrency 2, zero-retention, and expires in 7 days if
        never claimed. 503 `capacity` (honor Retry-After) when the unclaimed pool
        is full; 503 `agents_disabled` when registration is off.
      servers:
        - url: https://agents-v1.nyks.dev
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 64, description: 'A label the human sees on the claim page.' }
                client: { type: string, maxLength: 128 }
                requested_scopes:
                  type: array
                  items: { $ref: '#/components/schemas/Scope' }
                  description: 'A hint that pre-ticks a box on the claim page. Never grants anything.'
      responses:
        '201':
          description: The agent's key and claim material.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentRegisterResponse' }
        '400': { $ref: '#/components/responses/ControlPlaneError' }
        '429': { $ref: '#/components/responses/ControlPlaneError' }
        '503': { $ref: '#/components/responses/ControlPlaneError' }

  /v1/agents/me:
    get:
      tags: [agents]
      operationId: agentSelf
      summary: Self-lookup by key — rebuild a lost record.
      description: >
        The status body (identical to the poll), addressed by the `sr-` key alone.
        A client that lost its local record but still holds the key recovers its
        `agent_id`, status, scopes and claim metadata unattended. Unknown or
        revoked key → 404, exactly like the poll.
      servers:
        - url: https://agents-v1.nyks.dev
      responses:
        '200':
          description: The agent's status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentStatus' }
        '404': { $ref: '#/components/responses/ControlPlaneError' }
        '429': { $ref: '#/components/responses/ControlPlaneError' }

  /v1/agents/{agent_id}:
    get:
      tags: [agents]
      operationId: agentPoll
      summary: The agent's status poll (unclaimed / claimed / expired).
      description: >
        The key must belong to `agent_id` (else 404, like unknown). Diagnostic
        auth: a key whose tenant expired unclaimed still answers so the client can
        learn `expired` and stop.
      servers:
        - url: https://agents-v1.nyks.dev
      parameters:
        - name: agent_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The agent's status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentStatus' }
        '404': { $ref: '#/components/responses/ControlPlaneError' }
        '429': { $ref: '#/components/responses/ControlPlaneError' }

  /v1/agents/{agent_id}/claim-code:
    post:
      tags: [agents]
      operationId: agentRotateClaimCode
      summary: Recover the human claim link — mint a FRESH claim code (key-authed).
      description: >
        For a client that still holds the `sr-` key but lost the `claim_url` /
        `claim_code`. Authenticated by the key exactly like the poll; unclaimed
        agents only. Mints a fresh code and kills the old one (its hash is
        overwritten). The original is never re-served — only the code's hash is
        stored, and re-serving a claim URL is a takeover risk. The fresh code lives
        far shorter than the key (default 1h), never past the key's own expiry. A
        wrong key or an already-claimed/expired agent → 404. Tightly rate-limited.
      servers:
        - url: https://agents-v1.nyks.dev
      parameters:
        - name: agent_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: A fresh claim link.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClaimCodeResponse' }
        '404': { $ref: '#/components/responses/ControlPlaneError' }
        '429': { $ref: '#/components/responses/ControlPlaneError' }

  /v1/agents/enroll:
    post:
      tags: [agents]
      operationId: agentEnroll
      summary: A claimed agent mints a mining enrollment token.
      description: >
        Requires the key's tenant to be claimed with the `mining` scope. Returns
        the same 15-minute `enroll+jwt` the portal's Mining page mints. Off the
        search path; see the MINIS integration guide for redemption.
      servers:
        - url: https://agents-v1.nyks.dev
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slot]
              properties:
                slot: { type: string, example: twilight-slot-3 }
      responses:
        '200':
          description: The enrollment token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  slot: { type: string }
                  expires_at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/ControlPlaneError' }
        '403': { $ref: '#/components/responses/ControlPlaneError' }
        '409': { $ref: '#/components/responses/ControlPlaneError' }
        '501': { $ref: '#/components/responses/ControlPlaneError' }

components:
  securitySchemes:
    srKey:
      type: http
      scheme: bearer
      description: >
        A tenant or agent API key, prefixed `sr-`. Unknown/revoked/expired/disabled all
        answer the same mute 401. A key may be scoped at creation to a subset of
        `search` (search, stream, turns, feedback, judge), `read` (traces, GET limits,
        account) and `limits` (POST limits); a key with no scopes may do everything.
        See reference §6.2 for expiry, rotation with overlap and per-key limits.

  responses:
    GatewayError:
      description: Flat gateway error with a machine code.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/FlatError' }
    GatewayUnavailable:
      description: '`paused` (the platform-wide stop; retry shortly) or `overloaded` (the listener''s in-flight limit; `Retry-After: 1`). Both carry the flat envelope.'
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/FlatError' }
    GatewayRateLimited:
      description: '`rate_limited` (a request bucket is empty; `Retry-After` says when a token is back — minutes when the hour ran out) or `concurrency` (too many in flight; `Retry-After: 1`). Written by the gate before the rate headers'' layer runs, so a 429 carries `Retry-After` and none of the `X-RateLimit-*` headers.'
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/FlatError' }
    ScopeError:
      description: >
        The key is scoped and its scopes do not cover this route (code `scope`); the
        message names the missing scope. Raised after authentication and before any
        rate or concurrency counter moves. A key with no scopes never sees it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/FlatError' }
    ControlPlaneError:
      description: Nested control-plane error; `code` also mirrored at the top level.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/NestedError' }

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, minLength: 1, maxLength: 255, pattern: '^[\x21-\x7e]{1,255}$' }
      description: >
        A client-chosen token (1-255 printable ASCII characters; a UUID is the
        usual shape) that makes this request safe to retry. The first request
        under a key runs and its response is stored for 24 hours, scoped to the
        tenant; the same key with the same body (byte-identical) is answered
        with the stored response and `Idempotent-Replayed: true`, nothing runs
        and nothing is billed. Same key while the first is still running: 409
        `idempotency_in_progress`. Same key with a different body: 422
        `idempotency_key_reused`. An invalid key is 400 `invalid_request`.
        Ignored on /v1/search/stream. In-process and bounded (10k keys, 24h);
        a gateway restart forgets every key. Reference §6.4.
      x-stability: frozen

  headers:
    XRequestId:
      schema: { type: string, format: uuid }
      description: 'UUIDv7; the trace id, equal to the body''s `request_id`. Sent before any body bytes.'
    XRateLimitLimit:
      schema: { type: integer }
      description: 'The per-minute bucket''s size (`burst`). Absent when the tenant has no per-minute limit.'
    XRateLimitRemaining:
      schema: { type: integer, minimum: 0 }
      description: 'Whole tokens left in the per-minute bucket after this request was charged. The bucket refills continuously, so 0 is usable again before `X-RateLimit-Reset`.'
    XRateLimitReset:
      schema: { type: integer }
      description: 'Unix seconds at which the per-minute bucket is full again (not when the next token arrives — a 429''s `Retry-After` says that).'
    XRateLimitLimitHour:
      schema: { type: integer }
      description: 'The per-hour bucket''s size (`requests_per_hour`). Absent when the tenant has no hourly limit.'
    XRateLimitRemainingHour:
      schema: { type: integer, minimum: 0 }
      description: 'Whole tokens left in the per-hour bucket after this request was charged.'
    XRateLimitResetHour:
      schema: { type: integer }
      description: 'Unix seconds at which the per-hour bucket is full again.'
    IdempotentReplayed:
      schema: { type: string, const: 'true' }
      description: 'Present (and `true`) only when this response was replayed from the store behind `Idempotency-Key`: the first run''s response, byte for byte, nothing ran and nothing was billed. Reference §6.4.'
    RetryAfter:
      schema: { type: integer }
      description: 'Seconds to wait before sending the same request again. On a 429 it is exactly when the next token is back (minutes when the hourly bucket ran out); `1` on `409 idempotency_in_progress`, `503 overloaded` and `429 concurrency`. The two 402s and `503 paused` carry none.'

  schemas:

    # ---------- request ----------
    SearchRequest:
      type: object
      required: [query]
      properties:
        query:
          type: string
          maxLength: 4096
          description: 'Required, non-empty, <= 4096 bytes.'
          x-stability: frozen
        tier:
          allOf: [{ $ref: '#/components/schemas/Tier' }]
          description: 'Cost band. Empty = the tenant default, else `balanced`.'
          x-stability: frozen
        session_id:
          type: string
          description: 'Threads consecutive queries (also accepted as the `X-Session-Id` header). Grouping/labeling only — no context carry-over, caching, or billing effect. See reference §3.'
          x-stability: frozen
        providers:
          type: array
          items: { type: string }
          description: 'Explicit provider allowlist. Extended-tier arms fire only when named here; an unknown name is a 400. Marks the decision `requested`.'
          x-stability: frozen
        policy:
          type: string
          description: 'Plain-text routing hint (e.g. "prefer academic sources"). Recorded only; does not change routing.'
          x-stability: moving
        max_results:
          type: integer
          default: 8
          minimum: 0
          maximum: 25
          description: 'Per-provider result cap. Negative is a 400; then clamped: 0 -> the tier''s default, > 25 -> 25. The default is the tier''s: 8 on fast and balanced, 15 on max; a value the caller sends wins on every tier.'
          x-stability: frozen
        recency:
          allOf: [{ $ref: '#/components/schemas/Recency' }]
          description: 'Freshness window: 1, 7, 31 or 366 days back from the request, fixed as one instant (filters_applied.recency.published_after). Sent to every arm whose provider takes it (through Telem as search.published_after; natively on direct arms), and then held by the router itself: a result published before the instant is dropped, an undated one is kept. Part of the cache key.'
          x-stability: frozen
        domain_filter:
          type: array
          maxItems: 16
          items: { type: string }
          description: 'Up to 16 bare hostnames (no scheme/path/space/?/#); trimmed and lowercased. Sent to every arm whose provider takes it (through Telem as search.include_domains; natively or as site: operators on direct arms), and then held by the router itself: a result whose host is not one of these or a subdomain of one is dropped, whatever the provider did with the list. Part of the cache key.'
          x-stability: frozen
        trace:
          type: object
          description: 'Client lineage envelope, <= 64 KiB, must be a JSON object. Stored on the trace (content inside it obeys the tenant''s retention posture; structural lineage keys are always kept), never dispatched to a provider; a malformed/oversized one is dropped, never a reason the search fails. No sub-field influences a request. At rest, stored structural keys (session_id, parent_session_id, turn_id, window, seq) thread a project''s traces into session trajectories in the console (Threads); parent_session_id is a subagent''s pointer to the session that started it, same shape as session_id (1-128 of [A-Za-z0-9_-]). For a zero-retention tenant a structural key is kept only when its value is id-shaped. See reference §7.'
          x-stability: frozen
        view:
          type: string
          enum: [merged]
          description: 'Opt-in response view. Omitted (or "") = the default response, unchanged. "merged" = the default response plus `merged` and `indexes`, nothing else altered. Any other value, or a value that is not a string, is a 400 `invalid_request`. Not part of the cache key. See reference §5 "Merged view (opt-in)".'
          x-stability: frozen
        merged_limit:
          type: integer
          minimum: 1
          maximum: 50
          description: 'The merged view''s window: how many pages merged[] carries, best first. Default is the tier''s per search_queries form: 12 on fast and balanced, 24 on max, times one plus the validated forms, within 50; a value the caller sends wins. Needs view "merged"; refused without it (400 invalid_request).'
          x-stability: frozen
        max_chars:
          type: integer
          minimum: 1
          description: 'Total budget for the merged pages'' excerpts, in characters; excerpts are cut in order to fit and excerpts_trimmed says so. Needs view "merged". Under excerpt_mode "split" it is the total the entries share, titles, urls and framing included (default 12,000; 16,000 on max). Under "split" it is at most 32,000.'
          x-stability: frozen
        page_chars:
          type: integer
          minimum: 200
          maximum: 4000
          description: 'The per-page excerpt cap for this search, in characters, in place of the default. The per-page cap is, in order: the request''s page_chars, else the deployment''s [presentation.page_chars] for the request''s tier when it sets one, else 2,000 on max, else 1,200. Each entry of excerpts[] obeys the same cap; with merged_limit it trades pages for depth at the same total. Needs view "merged" and does not combine with excerpt_mode "split", which has no per-page cap; a value outside 200..4000 is refused (400 invalid_request). Applied after the cache lookup, like merged_limit, so it is not part of the cache key.'
          x-stability: moving
        excerpt_mode:
          type: string
          enum: [split]
          description: 'Experimental. How the merged view fills its excerpts. Omitted (or "") = the default. "split" = the first 10 pages share 60% of a total budget (max_chars; default 12,000 characters, 16,000 on max), each filled with its best-scoring sentences and no sentence repeated across pages; every later page in the window (40 by default; merged_limit wins) gets a compact entry of title, url and at most 100 characters until the budget runs out. Page order is unchanged. Needs view "merged" and tier balanced or max; otherwise, or any other value, or a value that is not a string, a 400 invalid_request. Not part of the cache key. See reference §5 "Merged view (opt-in)".'
        merge_mode:
          type: string
          enum: [votes, interleave]
          description: 'Experimental: may change or be removed, and a router whose deployment has not turned the experimental presentation controls on refuses it (400 invalid_request). The merged view''s order. "votes" (the default) is the order documented on MergedResult; "interleave" orders pages by their best rank in any listing, ties by the candidates[] position of the first listing that gave that rank (a search_queries form is a listing), never by votes — each provider''s list in turn, every rank 1, then every rank 2 — with no reserved slots and no score. Needs view "merged". Not part of the cache key. Reference §5 "Experimental presentation controls".'
          x-stability: moving
        excerpt_style:
          type: string
          enum: [stitched, single]
          description: 'Experimental: may change or be removed, and a router whose deployment has not turned the experimental presentation controls on refuses it (400 invalid_request). The merged pages'' text. "stitched" (the default) is every provider''s text joined; "single" is the one longest snippet any provider gave the page (else its longest excerpt), unjoined, cut at the tier''s per-page cap; excerpts[] then carries that one text. The order does not change. Needs view "merged". Not part of the cache key.'
          x-stability: moving
        exclude:
          type: array
          maxItems: 100
          items: { type: string }
          description: 'Experimental: may change or be removed, and a router whose deployment has not turned the experimental presentation controls on refuses it (400 invalid_request). Pages to leave out of merged[], each a source_id (^s_[0-9a-f]{32}$) or an absolute http(s) URL, which names its page under the merge''s identity rule (any spelling the merge folds). Removed before the window is cut, so the window refills; merged_total counts what is left, coverage still counts every page, candidates[] is unchanged, and nothing is remembered between requests. Any other entry, or more than 100, is a 400 invalid_request naming the index. Needs view "merged". Not part of the cache key.'
          x-stability: moving
        objective:
          type: string
          maxLength: 4096
          description: 'What the caller is trying to find out, in its own words. Forwarded to providers that take one; never used to rank; recorded on the trace.'
          x-stability: moving
        search_queries:
          type: array
          maxItems: 4
          items: { type: string, minLength: 1, maxLength: 4096 }
          description: 'Up to four reformulations of query, run beside it in the same call by every arm that batches (one billed call per form per arm). A repeat of query or of another entry is dropped. Pages carry the form that listed them in found_by[].form; coverage.by_form and reformulation_only count what the reformulations found.'
          x-stability: moving
      description: 'Decoded strictly: any unknown field is a 400. Whole body <= 64 KiB. There is no client-set `mode`.'
      additionalProperties: false

    Tier:
      type: string
      enum: [fast, balanced, max]
      description: >
        fast = the deployment's named fast set — live: exa, brave, perplexity-search, one arm per index group (routed per query family once a classifier is measured; without a named set, the cheapest arm; a routed single-pick once a student
        router exists); balanced = default, full fan-out with ranking; max =
        premium: the full fan-out with more work per query (15 results per arm, a
        24-page merged window, 2,000-character excerpts; fusion when configured),
        stays full fan-out forever. Fixed,
        finite set.
      x-stability: frozen
    Recency:
      type: string
      enum: ['', day, week, month, year]
      description: 'Empty = any window.'
      x-stability: frozen
    Scope:
      type: string
      enum: [credits, mining]
      x-stability: frozen

    # ---------- response ----------
    SearchResponse:
      type: object
      required: [request_id, query, chosen, candidates, signals, decision, usage]
      properties:
        request_id: { type: string, format: uuid, description: 'trace id; also the X-Request-Id header.', x-stability: frozen }
        query: { type: string, x-stability: frozen }
        chosen: { type: integer, description: 'Index into candidates; -1 in return-all mode or when every candidate failed.', x-stability: frozen }
        candidates:
          type: array
          items: { $ref: '#/components/schemas/Candidate' }
          x-stability: frozen
        signals: { $ref: '#/components/schemas/Signals' }
        decision: { $ref: '#/components/schemas/DecisionBlock' }
        usage: { $ref: '#/components/schemas/UsageBlock' }
        session:
          allOf: [{ $ref: '#/components/schemas/SessionBlock' }]
          description: 'Present only for a threaded request.'
        fused:
          allOf: [{ $ref: '#/components/schemas/Fusion' }]
          description: 'Present only in fuse mode (max tier).'
        fusion_failed:
          type: string
          description: 'Present only when a requested fusion could not run and fell back to rank.'
        merged:
          type: array
          items: { $ref: '#/components/schemas/MergedResult' }
          description: 'Present only when the request sent `view: "merged"`, and then always — `[]` when no candidate was usable. One entry per page across every usable candidate. Absent otherwise.'
          x-stability: frozen
        indexes:
          allOf: [{ $ref: '#/components/schemas/IndexesSummary' }]
          description: 'Present only when the request sent `view: "merged"`: how each arm selected for this search ended. Absent otherwise.'
          x-stability: frozen
        coverage:
          $ref: '#/components/schemas/Coverage'
          description: 'Present with the merged view: what the union of indexes found, over every page, not only the window.'
          x-stability: moving
        merged_limit: { type: integer, description: 'Present with the merged view: the window applied (the request''s, or the tier''s default: 12 per form, 24 on max; 40 under excerpt_mode "split").', x-stability: frozen }
        merged_total: { type: integer, description: 'Present with the merged view: how many pages there were before the window.', x-stability: frozen }
        excerpts_trimmed: { type: boolean, description: 'Present with the merged view and only when true: the max_chars budget cut at least one excerpt. Under excerpt_mode "split": the budget left out a page of the window, or some of a shown page''s text.', x-stability: frozen }
        filters_applied:
          allOf: [{ $ref: '#/components/schemas/FiltersApplied' }]
          description: 'Present only when the request carried recency or domain_filter, and then only the keys for the filters it carried. Absent otherwise.'
        presentation:
          allOf: [{ $ref: '#/components/schemas/Presentation' }]
          description: 'Experimental: may change or be removed; only a router with the experimental presentation controls turned on sends it. Present only when the request set merge_mode, excerpt_style or exclude: what the merged view was presented under. Absent otherwise, so a default response does not change.'
          x-stability: moving

    Candidate:
      type: object
      required: [provider, kind, tier, usage, cost_micros, latency, status]
      properties:
        provider: { type: string, x-stability: frozen }
        model: { type: string }
        kind: { $ref: '#/components/schemas/CandidateKind' }
        tier:
          type: string
          enum: [core, extended]
          description: 'The PROVIDER tier (dispatch band) — not the product tier.'
          x-stability: frozen
        answer: { type: string, description: 'Answer arms only.' }
        citations:
          type: array
          items: { $ref: '#/components/schemas/Citation' }
        agent:
          allOf: [{ $ref: '#/components/schemas/AgentMeta' }]
          description: 'Agentic providers only; null elsewhere.'
        usage: { $ref: '#/components/schemas/CandidateUsage' }
        cost_micros: { type: integer, format: int64, description: 'Micro-dollars (1e-6 USD).', x-stability: frozen }
        cost_source: { $ref: '#/components/schemas/CostSource' }
        latency: { $ref: '#/components/schemas/Latency' }
        status: { $ref: '#/components/schemas/CandidateStatus' }
        error: { type: string, description: 'Non-ok only; redacted, client-safe.' }
      description: 'Failures are kept in the list, never dropped. Usable = status ok AND (answer or >= 1 citation).'

    Presentation:
      type: object
      required: [merge_mode, excerpt_style]
      description: 'Experimental: the echo of the experimental presentation controls a request set; a router may refuse those controls when its deployment has not turned them on. Reference §5 "Experimental presentation controls".'
      properties:
        merge_mode: { type: string, enum: [votes, interleave], description: 'Experimental: the order applied, the default included.', x-stability: moving }
        excerpt_style: { type: string, enum: [stitched, single], description: 'Experimental: the text style applied, the default included.', x-stability: moving }
        exclude:
          type: object
          required: [requested, dropped]
          description: 'Experimental: present only when the request carried exclude.'
          properties:
            requested: { type: integer, description: 'Experimental: how many entries the request''s exclude carried.', x-stability: moving }
            dropped: { type: integer, description: 'Experimental: how many pages they removed from merged[].', x-stability: moving }
          x-stability: moving

    MergedResult:
      type: object
      required: [result_id, source_id, url, domain, best_rank, found_by]
      properties:
        result_id:
          type: string
          pattern: '^r_[0-9a-f]{32}$'
          description: '"r_" + the first 32 lowercase hex characters (128 bits) of SHA-256(request_id + "\n" + url). Derived, never stored: it never changes for a given request_id, and any client holding the response can recompute it. The same url in two searches has two ids.'
          x-stability: frozen
        source_id:
          type: string
          pattern: '^s_[0-9a-f]{32}$'
          description: '"s_" + the first 32 lowercase hex characters (128 bits) of SHA-256(canonical_url). Names the page across searches: the request id is not in it, so the same page in two searches, under any spelling the merge folds into one page, has one source_id where it has two result_ids. Derived, never stored; a client can recompute it from canonical_url. New in 2026-10-23.'
          x-stability: frozen
        url:
          type: string
          description: 'The page''s first citation url in candidate order, exactly as that adapter emitted it. Cite this, never canonical_url (reference §5).'
          x-stability: frozen
        canonical_url: { type: string, description: 'The key the page was merged under: scheme folded to https, default port dropped, then the per-candidate rule. Informational.', x-stability: moving }
        domain: { type: string, description: 'From the page''s first citation in candidate order.', x-stability: frozen }
        title: { type: string, description: 'First non-empty title in candidate order. Under excerpt_mode "split", whitespace-collapsed and cut to 100 characters.' }
        excerpt: { type: string, description: 'excerpts[] joined with " … ", cut on a rune boundary at the page cap (the request''s page_chars, else the deployment''s [presentation.page_chars] for the request''s tier when it sets one, else 2,000 on max, else 1,200) and then at what the request''s max_chars budget left for this page. Under excerpt_mode "split": the page''s selected sentences joined with " … " — the whole of what the view shows for the page.' }
        excerpts: { type: array, items: { type: string }, description: 'Every distinct text any provider gave for the page (snippets and excerpts), deduplicated under a lenient key, the text densest in identifiers first, then first-seen order. Each entry is cut on a rune boundary at the same page cap as excerpt, and an entry identical to an earlier one after its cut is dropped. Absent under excerpt_mode "split": excerpt carries the selection, and a client that copies excerpts[] beside excerpt would hand the agent every sentence twice.' }
        compact: { type: boolean, description: 'Experimental. Present (true) only under excerpt_mode "split", on a page past the first 10: a title, a url and at most 100 characters of text in excerpt.', x-stability: moving }
        groups: { type: integer, minimum: 1, description: 'How many distinct index groups returned the page; two proxies of one index (serper and serpapi both read Google) are one group.', x-stability: frozen }
        votes: { type: integer, minimum: 1, description: 'The page''s corroboration: distinct (group, query form) pairs that listed it. The primary sort key.', x-stability: frozen }
        exclusive: { type: boolean, description: 'True when exactly one group returned the page.', x-stability: moving }
        score: { type: number, description: 'The order as one number: votes × 10 + 1 / (1 + best_rank); sorting by it descending gives the merged order (ties keep first appearance). matched_terms is not in it.', x-stability: moving }
        matched_terms: { type: array, items: { type: string }, description: 'The query''s identifiers (paths, snake_case and camelCase names, backticked tokens) found in the page''s path, title or text as the providers gave it, before the per-page cut (a term may sit past the end of excerpt and of every excerpts[] entry). Informational; they do not move the order.', x-stability: moving }
        published: { type: string, format: date-time, description: 'First non-null in candidate order.' }
        best_rank: { type: integer, minimum: 1, description: 'The minimum of found_by[].rank.', x-stability: frozen }
        found_by:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/FoundBy' }
          description: 'One entry per contributing candidate, in candidate order, at that candidate''s first citation of this url.'
          x-stability: frozen
      description: 'Ordered by votes descending, then best_rank ascending, then first appearance (candidate order, then rank). Built only from usable candidates; independent of chosen, signals.ranking, fused and the query. Cut to merged_limit.'
    Coverage:
      type: object
      required: [pages, exclusive, corroborated, groups, by_group, exclusive_by_group]
      properties:
        pages: { type: integer, description: 'Distinct pages across every usable arm.' }
        exclusive: { type: integer, description: 'Pages exactly one index group returned.' }
        corroborated: { type: integer, description: 'Pages two or more index groups returned.' }
        groups: { type: integer, description: 'Index groups that returned at least one page.' }
        by_group: { type: object, additionalProperties: { type: integer }, description: 'Pages per index group.' }
        exclusive_by_group: { type: object, additionalProperties: { type: integer }, description: 'Pages only that group returned: its own contribution to coverage.' }
        by_form: { type: array, items: { type: integer }, description: 'With search_queries: pages each spelling found, index 0 the query itself.' }
        reformulation_only: { type: integer, description: 'With search_queries: pages no listing of the query itself found.' }
      description: 'The fan-out''s own account of itself, over every page the union found, not only the window.'
    FoundBy:
      type: object
      required: [provider, rank]
      properties:
        provider: { type: string, description: 'Opaque provider name, as in candidates[].provider.' }
        rank: { type: integer, minimum: 1, description: 'That provider''s own 1-based rank for the page (citations[].rank).' }
        form: { type: integer, minimum: 1, description: 'Which spelling of the query this listing came from: absent for query itself, n for the n-th search_queries entry.', x-stability: moving }
    IndexesSummary:
      type: object
      required: [selected, ok, pending, by_provider]
      properties:
        selected: { type: integer, description: 'Arms selected for this search: len(decision.providers), which is also len(candidates). Not the deployment''s provider count — arms trimmed for cost are in decision.trimmed and are not rows here.' }
        ok: { type: integer, description: 'Rows with status ok — including an arm that executed and found nothing.' }
        pending: { type: integer, description: 'Rows with status pending.' }
        by_provider:
          type: array
          items: { $ref: '#/components/schemas/IndexExecution' }
          description: 'One row per candidate, in candidate order.'
      x-stability: frozen
    IndexExecution:
      type: object
      required: [provider, status]
      properties:
        provider: { type: string }
        status: { $ref: '#/components/schemas/CandidateStatus' }
        reason:
          allOf: [{ $ref: '#/components/schemas/SkipReason' }]
          description: 'Present only when the dispatcher decided the arm''s fate without a provider answer.'
        results:
          type: integer
          minimum: 0
          description: 'The arm''s citation count: present on every ok row, 0 when the arm returned nothing; absent otherwise.'
        latency_ms: { type: integer, description: 'candidates[].latency.total_ms, when known (non-zero).' }
      x-stability: frozen
    SkipReason:
      type: string
      enum: [no_credential, breaker_open, slow_lane_saturated, provider_capacity, deadline_elapsed, served_from_cache]
      description: >
        Why an arm ended without a provider answer, as a word to branch on
        (candidates[].error keeps the prose). no_credential = nobody can pay for
        the arm on this request; breaker_open = the provider's circuit breaker
        refused the call; slow_lane_saturated = the slow lane had no free slot;
        provider_capacity = every in-flight slot for this arm, across all
        tenants, was taken (the gateway's load, not the provider's fault: the
        breaker is untouched and the rest of the fan-out answers);
        deadline_elapsed = the shared fan-out deadline fired before the arm
        answered; served_from_cache = the response came from the cache and this
        request made no call to a slow arm that was pending when it was cached.
        Execution reasons only: policy trimming is decision.trimmed. Delivered
        only in indexes.by_provider[].reason; treat an unknown value as opaque.
      x-stability: frozen

    CandidateKind:
      type: string
      enum: [answer, retrieval]
      description: 'answer = prose + citations; retrieval = ranked results, no synthesis.'
      x-stability: frozen
    CandidateStatus:
      type: string
      enum: [ok, timeout, deadline, error, auth, quota, skipped, pending]
      description: >
        ok = the arm executed (usable only with an answer or >= 1 citation);
        timeout = the provider's own deadline fired; deadline = the shared
        fan-out deadline fired first; error = upstream fault; auth = the
        provider rejected the credential (401/403 — a configuration problem,
        retrying will not help); quota = the provider is rate-limited or out of
        budget (429 — transient, back off); skipped = never dispatched (no
        credential, breaker open, or budget); pending = slow-lane arm still
        running (not a failure — it will bill and land on the trace).
      x-stability: frozen
    CostSource:
      type: string
      enum: [modeled, reported]
      description: 'modeled = priced from the rate card; reported = the provider stated the cost (that number wins). The usage ledger additionally records "cache" for an arm served from the cache; that value never appears on a response candidate.'
      x-stability: frozen

    Citation:
      type: object
      properties:
        url: { type: string }
        domain: { type: string }
        title: { type: string }
        snippet: { type: string }
        rank: { type: integer }
        published: { type: string }
        excerpts: { type: array, items: { type: string } }
    CandidateUsage:
      type: object
      properties:
        prompt_tokens: { type: integer }
        completion_tokens: { type: integer }
        total_tokens: { type: integer }
        search_calls: { type: integer }
        reported_cost_micros: { type: integer, format: int64 }
        cost_reported: { type: boolean }
    Latency:
      type: object
      properties:
        ttfb_ms: { type: integer }
        total_ms: { type: integer }
    AgentMeta:
      type: object
      description: 'A research arm''s own account of what it did.'
      properties:
        run_id: { type: string }
        effort: { type: string }
        status: { type: string }
        steps: { type: integer }
        events_seen: { type: integer }
        partial: { type: boolean, description: 'true = capture incomplete; do not read absence as silence.' }
        plan: { type: array, items: { $ref: '#/components/schemas/AgentStep' } }
        sources: { type: array, items: { $ref: '#/components/schemas/AgentSource' } }
        tool_calls: { type: object, additionalProperties: { type: integer }, description: 'Count keyed by the arm invoked.' }
        fields: { type: array, items: { $ref: '#/components/schemas/FieldBasis' } }
    AgentStep:
      type: object
      properties:
        at_ms: { type: integer }
        kind: { type: string, enum: [objective, query, status, tool, state] }
        text: { type: string }
    AgentSource:
      type: object
      properties:
        at_ms: { type: integer }
        url: { type: string }
        title: { type: string }
    FieldBasis:
      type: object
      properties:
        field: { type: string }
        confidence: { type: string }
        reasoning: { type: string }
        citations: { type: array, items: { $ref: '#/components/schemas/Citation' } }

    DecisionBlock:
      type: object
      required: [mode, tier, provider_set, providers, propensity, router_version, ranker_version, build]
      properties:
        mode: { type: string, enum: [all, rank, fuse], x-stability: frozen }
        tier: { $ref: '#/components/schemas/Tier' }
        provider_set: { type: string, description: 'A stable key naming the set (e.g. "exa+openai+perplexity").' }
        providers: { type: array, items: { type: string } }
        propensity: { type: number, description: 'Fixed 1.0 in the fan-out era.', x-stability: moving }
        router_version: { type: string, description: '"none" until a student router exists.', x-stability: moving }
        ranker_version: { type: string }
        slice: { type: boolean }
        requested: { type: boolean, description: 'true when the caller named providers.' }
        trimmed: { type: array, items: { type: string }, description: 'Arms dropped to fit the per-query cost ceiling.' }
        build: { $ref: '#/components/schemas/BuildInfo' }

    BuildInfo:
      type: object
      description: 'What answered this request. Compare the values; do not parse them. Not router_version, which is the learned router''s.'
      required: [version, config_hash]
      properties:
        version: { type: string, description: 'The gateway build: the commit''s short sha on a deployed image, "dev" on an unstamped build. Also on the admin /statusz.', x-stability: moving }
        config_hash: { type: string, description: 'Twelve hex characters hashing the provider set each tier resolves to as loaded (each arm''s name, model, lane and credential, sorted). Moves when a tier''s set changes, and only then, so the same version with a different hash is the same binary on a different configuration. Also on the admin /statusz.', x-stability: moving }

    FiltersApplied:
      type: object
      description: 'The account of the request''s filters. sent_to lists the selected arms whose provider request carried the filter, through Telem or natively (including as site: operators); not_sent lists the rest: fixtures, and arms whose provider takes no such parameter or gets it only as a prompt hint. Both are in decision.providers order. Every arm''s results are held to the filter by the router, sent or not; dropped counts what that check removed, across the arms that have landed (a slow arm''s count joins the stored trace when it lands). A result off both filters counts once, under domain_filter.'
      properties:
        recency: { $ref: '#/components/schemas/RecencyApplied' }
        domain_filter: { $ref: '#/components/schemas/DomainFilterApplied' }
      x-stability: moving

    RecencyApplied:
      type: object
      required: [requested, published_after, sent_to, not_sent, dropped]
      properties:
        requested: { $ref: '#/components/schemas/Recency' }
        published_after: { type: string, format: date-time, description: 'The window as one instant, RFC 3339 UTC to the second: what every arm was sent and every result checked against.' }
        sent_to: { type: array, items: { type: string } }
        not_sent: { type: array, items: { type: string } }
        dropped: { type: integer, description: 'Results removed for a publication date before published_after. An undated result is kept.' }

    DomainFilterApplied:
      type: object
      required: [requested, sent_to, not_sent, dropped]
      properties:
        requested: { type: array, items: { type: string }, description: 'The hosts as validated (trimmed, lowercased).' }
        sent_to: { type: array, items: { type: string } }
        not_sent: { type: array, items: { type: string } }
        dropped: { type: integer, description: 'Results removed for a host that is not a requested host or a subdomain of one (www. and case folded).' }

    UsageBlock:
      type: object
      required: [cost_micros, latency_ms, cache_hit, deadline_hit, byok]
      properties:
        cost_micros: { type: integer, format: int64, description: 'What this request was charged, in micro-dollars: the dispatched arms at their recorded cost plus the fusion call on max. A FLOOR while pending > 0 — slow arms bill later under the same request id. 0 on a cache hit (the fusion cost alone on max): no provider was called.', x-stability: frozen }
        latency_ms: { type: integer, format: int64 }
        cache_hit: { type: boolean, description: 'true = served from the response cache. cost_micros is then 0 (the fusion cost alone on max) and nothing is drawn from the balance or the daily ceiling; the candidates keep the cost of the call that filled the cache, so cost_micros is not their sum.' }
        deadline_hit: { type: boolean }
        byok: { type: boolean, description: 'true = tenant''s own provider keys; these do not draw the daily ceiling.' }
        pending: { type: integer, description: 'Slow-lane arms still running.' }

    SessionBlock:
      type: object
      required: [id, query_index]
      properties:
        id: { type: string }
        query_index: { type: integer, description: 'Monotonic position in the chain (>= 1).' }
        reformulation_of: { type: string, description: 'The earlier trace id this query appears to revise.' }
        similarity: { type: number }

    Signals:
      type: object
      required: [referee, consensus, ranking]
      description: 'Deterministic evidence per candidate and across them. See reference §5 for field-level detail.'
      properties:
        referee: { type: array, items: { type: object, additionalProperties: true } }
        consensus: { type: object, additionalProperties: true }
        ranking:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              provider: { type: string }
              score: { type: number }
              base_score: { type: number }
          description: 'Feature vectors are stripped from the client response.'

    Fusion:
      type: object
      properties:
        answer: { type: string }
        citations: { type: array, items: { $ref: '#/components/schemas/Citation' } }
        model: { type: string }
        usage: { $ref: '#/components/schemas/CandidateUsage' }
        cost_micros: { type: integer, format: int64 }
        latency_ms: { type: integer }

    # ---------- streaming ----------
    StreamEvent:
      oneOf:
        - { $ref: '#/components/schemas/StreamStart' }
        - { $ref: '#/components/schemas/StreamAgentStep' }
        - { $ref: '#/components/schemas/StreamCandidate' }
        - { $ref: '#/components/schemas/StreamDone' }
        - { $ref: '#/components/schemas/StreamComplete' }
        - { $ref: '#/components/schemas/StreamError' }
      discriminator: { propertyName: type }
    StreamStart:
      type: object
      required: [type, request_id]
      properties:
        type: { const: start }
        request_id: { type: string, format: uuid }
        query: { type: string }
        tier: { $ref: '#/components/schemas/Tier' }
        mode: { type: string }
        providers: { type: array, items: { type: string } }
        provider_set: { type: string }
        trimmed: { type: array, items: { type: string } }
        deadline_ms: { type: integer }
    StreamAgentStep:
      type: object
      required: [type, provider]
      properties:
        type: { const: agent }
        provider: { type: string }
        elapsed_ms: { type: integer }
        kind: { type: string, enum: [objective, query, status, tool, state] }
        text: { type: string }
    StreamCandidate:
      type: object
      required: [type, candidate]
      properties:
        type: { const: candidate }
        elapsed_ms: { type: integer }
        late: { type: boolean, description: 'true = a research arm that landed after `done`; the selection does not change.' }
        candidate: { $ref: '#/components/schemas/Candidate' }
    StreamDone:
      type: object
      required: [type, response]
      properties:
        type: { const: done }
        response:
          allOf: [{ $ref: '#/components/schemas/SearchResponse' }]
          description: 'Byte-identical to what POST /v1/search would return.'
    StreamComplete:
      type: object
      required: [type]
      properties:
        type: { const: complete }
        elapsed_ms: { type: integer }
        late_arms: { type: integer }
        total_cost_micros: { type: integer, format: int64, description: 'The final total, including late arms.' }
    StreamError:
      type: object
      description: 'Defined but not currently emitted; rejections happen before the stream as a normal 4xx.'
      required: [type]
      properties:
        type: { const: error }
        error: { type: string }

    # ---------- traces ----------
    TraceSummary:
      type: object
      description: 'The listing view of one trace: what a request was and what it did, never what came back.'
      required: [trace_id, at, tier, mode, provider_set, providers, statuses, cost_micros, latency_ms, cache_hit, deadline_hit, contested]
      properties:
        trace_id: { type: string, description: 'The request_id; a UUIDv7.' }
        at: { type: string, format: date-time, description: 'When the trace was stored (last written — an amendment rewrites it).' }
        query: { type: string, description: 'Omitted for a zero-retention tenant.' }
        tier: { type: string }
        mode: { type: string }
        provider_set: { type: string }
        providers: { type: array, items: { type: string } }
        chosen: { type: string, description: 'Omitted when nothing was chosen.' }
        statuses: { type: array, items: { type: string }, description: 'Per provider, in providers order.' }
        cost_micros: { type: integer, format: int64 }
        latency_ms: { type: integer, format: int64 }
        cache_hit: { type: boolean }
        deadline_hit: { type: boolean }
        pending: { type: integer, description: 'Slow-lane arms still out; omitted when none.' }
        contested: { type: boolean }
    TraceWindow:
      type: object
      description: 'The retention this gateway actually offers, so a 404 for an old id is interpretable.'
      required: [ttl, max_entries, durable]
      properties:
        ttl: { type: string, description: 'The in-process window''s TTL, as a Go duration ("1h0m0s").' }
        max_entries: { type: integer, description: 'The in-process window''s size.' }
        durable: { type: boolean, description: 'True when a store behind the window survives a restart and answers listings; it keeps each trace for durable_ttl.' }
        durable_ttl: { type: string, description: 'How long the durable store keeps a trace ("720h0m0s"); absent when there is none.' }
    TraceList:
      type: object
      required: [traces, count, next_before, window]
      properties:
        traces: { type: array, items: { $ref: '#/components/schemas/TraceSummary' }, description: 'One page, newest first.' }
        count: { type: integer, description: 'len(traces).' }
        next_before: { type: string, description: 'The `before` of the next page; empty when this page reaches the end.' }
        window: { $ref: '#/components/schemas/TraceWindow' }
    TraceDetail:
      type: object
      description: 'The stored response document plus storage metadata and, while pending, live_thinking.'
      additionalProperties: true
      properties:
        trace_id: { type: string }
        stored_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        summary: { type: object, additionalProperties: true }
        response: { $ref: '#/components/schemas/SearchResponse' }
        live_thinking:
          type: object
          additionalProperties: { type: array, items: { $ref: '#/components/schemas/AgentStep' } }
          description: 'Present while a slow arm is still out; {provider: [steps]}.'
    FeedbackRequest:
      type: object
      required: [trace_id, events]
      properties:
        trace_id: { type: string }
        events:
          type: array
          items:
            type: object
            required: [type]
            properties:
              type:
                type: string
                enum: [result.fetched, result.cited, result.rejected, query.reformulated, session.abandoned, answer.accepted, judge.agree, judge.disagree]
              url: { type: string, description: 'Required for the result.* events.' }
              provider: { type: string }
    FetchRequest:
      type: object
      required: [pages]
      properties:
        pages:
          type: array
          minItems: 1
          maxItems: 5
          items: { $ref: '#/components/schemas/FetchPageRef' }
          x-stability: moving
        max_chars: { type: integer, minimum: 1000, maximum: 50000, default: 10000, description: 'Characters (not bytes) returned per page.', x-stability: moving }
        offset: { type: integer, minimum: 0, default: 0, description: 'Character offset into each page to start at; a page''s previous `next_offset`.', x-stability: moving }
        query: { type: string, maxLength: 1000, description: 'Experimental. With `offset` 0, each slice starts at the paragraph holding the most distinct query words of 3+ characters (case-insensitive, ties to the earliest; the top when none match); the page''s `offset` says where. It searches only the text the router holds (on a truncated page, the part before the cut). Ignored when `offset` > 0.', x-stability: moving }
    FetchPageRef:
      type: object
      description: 'Exactly one of `source_id` or `url`; both or neither is that page''s `invalid`.'
      properties:
        source_id: { type: string, pattern: '^s_[0-9a-f]{32}$', description: 'A merged view''s `source_id`.' }
        url: { type: string, description: 'A URL one of your searches returned, in any spelling the merge folds into one page (resolved as source_id).' }
    FetchResponse:
      type: object
      required: [request_id, pages, usage]
      properties:
        request_id: { type: string, description: '== `X-Request-Id`.' }
        pages:
          type: array
          items: { $ref: '#/components/schemas/FetchPage' }
        usage:
          type: object
          required: [cost_micros, latency_ms]
          properties:
            cost_micros: { type: integer, format: int64, description: 'The sum of the pages'' cost_micros.' }
            latency_ms: { type: integer, format: int64 }
      x-stability: moving
    FetchPage:
      type: object
      required: [status, next_offset, cached, cost_micros]
      properties:
        source_id: { type: string }
        url: { type: string, description: 'On a resolved page, the URL your search returned (what was fetched); otherwise the URL you sent, if any.' }
        title: { type: string }
        status:
          type: string
          enum: [ok, failed, not_recent, invalid]
          description: '`ok`; `failed` (see `reason`); `not_recent` (not in this account''s search results of the last 24 hours, or the gateway restarted since); `invalid` (a malformed id or URL). Only `ok` is charged.'
        content: { type: string, description: 'ok only: at most max_chars characters from offset, cut on a rune boundary, preferring the last paragraph or line break; "" when offset is at or past the end. The upstream''s markdown with link markup removed: images dropped, links reduced to their text, reference definitions dropped, runs of spaces collapsed and 3+ newlines made 2, and backslash escapes undone (ZFM\_IMPORT\_SRV is ZFM_IMPORT_SRV); fenced code, inline code and bare URLs untouched.' }
        offset: { type: integer, description: 'ok only: the offset the slice starts at (the query''s paragraph, when one was picked).' }
        next_offset: { type: [integer, 'null'], description: 'The offset to read on from; null when the page is exhausted (and on every non-ok page).' }
        content_length: { type: integer, description: 'ok only: the length in characters of the text the router holds for the page and pages through with offset/next_offset, counted after link markup is removed. At most the upstream''s inline cap (200,000 characters on beta); a page longer than that carries truncated and full_length.' }
        truncated: { type: boolean, enum: [true], description: 'ok only, and only when the upstream cut the page: content_length characters are all that can be read, out of full_length. Absent when the page came back whole.' }
        full_length: { type: integer, description: 'Present with truncated: the page''s full length in characters as the upstream measured it, before link markup was removed — so not in the same units as content_length.' }
        provider: { type: string, description: 'Which fetch provider returned the page.' }
        reason:
          type: string
          enum: [upstream_failed, unrendered]
          description: 'On a failed page only. `upstream_failed`: the upstream could not fetch it, or its call failed as a whole (a timeout, the network, a 5xx) — not cached, so the same request again tries again. `unrendered`: the upstream returned a JavaScript app''s loading shell ("Loading application…") instead of the page — it needs a browser; use another source. Neither is charged.'
        cached: { type: boolean, description: 'Served from the 15-minute fetch cache: no upstream call; charged under the cache policy (free as deployed).' }
        cost_micros: { type: integer, format: int64 }
      x-stability: moving
    TurnEndRequest:
      type: object
      required: [session_id, turn_id]
      description: 'Unknown fields are ignored (unlike SearchRequest). Whole body <= 512 KiB.'
      properties:
        session_id:
          type: string
          pattern: '^[A-Za-z0-9_-]{1,128}$'
          description: 'The trace.session_id the turn''s searches carried.'
        turn_id:
          type: string
          maxLength: 128
          description: 'The trace.turn_id the turn''s searches carried.'
        parent_session_id:
          type: string
          description: 'Set by a subagent, as in the envelope. A value that is not session-id shaped is ignored.'
        harness: { type: string, maxLength: 64 }
        status:
          type: string
          enum: [completed, interrupted, failed]
          description: 'Default completed. Only a completed turn keeps final_text.'
        final_text:
          type: string
          description: 'The assistant''s last message of the turn, UTF-8, <= 64 KiB. Stored as sent: the client scrubs and caps it.'
        final_chars: { type: integer, description: 'Its length before the client''s cap.' }
        truncated: { type: boolean, description: 'Whether the client''s cap cut it.' }
        user_text:
          type: string
          description: 'What the user asked in this turn, UTF-8, <= 64 KiB.'
        user_chars: { type: integer }
        user_truncated: { type: boolean }
        model: { type: string, maxLength: 96 }
        usage:
          type: object
          properties:
            input_tokens: { type: integer, minimum: 0 }
            output_tokens: { type: integer, minimum: 0 }
        steps:
          type: array
          maxItems: 400
          description: 'The turn in order; <= 256 KiB of assistant text between them. A step of an unknown kind is skipped.'
          items: { $ref: '#/components/schemas/TurnStep' }
    TurnStep:
      type: object
      required: [kind]
      description: >
        assistant = something the model wrote (`text`, `chars`, `truncated`).
        tool = a tool that is not a search of ours: `name`, `ms`, `ok` and
        nothing else — there is no field for a tool's input or output, and text
        sent on a tool step is not stored. search = a search made through this
        router: the `call_id` its envelope carried, `ms`, `ok`.
      properties:
        kind: { type: string, enum: [assistant, tool, search] }
        text: { type: string }
        chars: { type: integer }
        truncated: { type: boolean }
        name: { type: string, pattern: '^[A-Za-z0-9_.:-]{1,96}$' }
        ms: { type: integer, minimum: 0 }
        ok: { type: boolean }
        call_id: { type: string, maxLength: 128 }
    TurnEndAccepted:
      type: object
      required: [accepted, stored, content_stored]
      properties:
        accepted: { type: boolean }
        stored: { type: boolean, description: 'Whether the turn end was written.' }
        content_stored: { type: boolean, description: 'Whether any text was kept (false for a zero-retention tenant).' }
        reason:
          type: string
          enum: [no_search_in_session, no_store]
          description: 'Present when stored is false. Neither is worth a retry.'
    JudgeResponse:
      type: object
      properties:
        trace_id: { type: string }
        verdict:
          type: object
          additionalProperties: true
          description: 'judge_version, model, ranking (tiers), rank_score, grades (0-3), mean_grade, reasoning, blinding.'

    # ---------- agents ----------
    AgentRegisterResponse:
      type: object
      properties:
        agent_id: { type: string, format: uuid }
        key: { type: string, description: 'The sr- key, shown once.' }
        claim_url: { type: string, description: 'The portal page a human visits to claim (embeds the code).' }
        claim_code: { type: string, description: 'The claim code, shown here only.' }
        claim_expires_at: { type: string, format: date-time }
        poll:
          type: object
          properties:
            url: { type: string }
            interval_s: { type: integer }
        tier: { type: string, const: unclaimed }
    AgentStatus:
      type: object
      required: [agent_id, status, scopes, claim_expires_at, mining]
      properties:
        agent_id: { type: string, format: uuid, description: 'Present on every response, unclaimed included — what makes /me a recovery path.', x-stability: frozen }
        status: { type: string, enum: [unclaimed, claimed, expired], x-stability: frozen }
        scopes: { type: array, items: { $ref: '#/components/schemas/Scope' } }
        claim_expires_at: { type: string, format: date-time }
        claimed_at: { type: string, format: date-time, description: 'Claimed only.' }
        project_id: { type: string, format: uuid, description: 'Claimed only.' }
        console_url: { type: string, description: 'Claimed only — the project page on the portal.' }
        mining:
          type: object
          properties:
            available: { type: boolean }
            slots: { type: array, items: { type: string } }
            participant_has_other_agent: { type: boolean, description: 'UX-only: does the same participant already run another mining agent.' }
            last_enrollment:
              type: object
              properties:
                slot: { type: string }
                minted_at: { type: string, format: date-time }
    ClaimCodeResponse:
      type: object
      properties:
        claim_url: { type: string }
        claim_code: { type: string }
        claim_expires_at: { type: string, format: date-time, description: 'Short window (default 1h), never past the key''s own expiry.' }

    # ---------- errors ----------
    ErrorCode:
      type: string
      description: >
        Stable machine code. Read `code` at the TOP level on every host; every
        gateway refusal carries one. Only `trace_unsupported` invites a retry of
        the same request (once, without the trace field); the 429s, the 503s and
        idempotency_in_progress invite the same request again after Retry-After;
        every other code is FINAL. The complete catalogue, with statuses, is the
        reference's §6 code table (a router test holds it to the source).
        Gateway — gate: missing_key, bad_key (unknown/revoked/expired/disabled), scope (403), credits_exhausted (402),
        spend_ceiling (402), rate_limited (429), concurrency (429), paused (503),
        unauthenticated. Guard: unknown_route (404), method_not_allowed (405),
        invalid_target (400), upgrade_unsupported (400), unsupported_media_type
        (415), body_too_large (413), overloaded (503), not_ready (503). Search:
        trace_unsupported, malformed_json, invalid_tier, invalid_query,
        invalid_request, budget_exceeded (402), idempotency_key_reused (422),
        idempotency_in_progress (409). Traces/feedback/judge/limits:
        trace_not_found (404), traces_disabled (404), trace_no_content (400),
        not_claimed (403), judge_failed (502), judge_disabled (503), internal
        (500), reload_failed (502). Fetch: fetch_unavailable (403),
        fetch_disabled (503, final on this deployment); an upstream failure is
        a page's reason, never a request's code.
        Control plane / agents: not_found (404), conflict (409), bad_request (400),
        rate_limited (429), unauthorized (401), agents_disabled (503), capacity
        (503, Retry-After), not_claimed (403), mining_not_granted (403),
        attribution_unavailable (409), unknown_slot (400), minis_disabled (501),
        bad_scope (400), billing_disabled (501).
      examples: [trace_unsupported, invalid_tier, credits_exhausted, not_found]
    FlatError:
      type: object
      description: 'Gateway (router-api.nyks.dev) error envelope: every refusal, with a code.'
      required: [error, code]
      properties:
        error: { type: string, description: 'Human message; may change. Do not branch on it.' }
        code: { $ref: '#/components/schemas/ErrorCode' }
    NestedError:
      type: object
      description: 'Control-plane (agents-v1 / platform) error. `code` mirrored at the top level so it reads at the same path as the gateway.'
      required: [error, code]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { $ref: '#/components/schemas/ErrorCode' }
            message: { type: string }
        code: { $ref: '#/components/schemas/ErrorCode' }
    LimitsResponse:
      type: object
      description: 'A project''s spend caps and its spend against them: today against the enforced ceiling, the week and the month against their own caps (GET/POST /v1/limits).'
      required: [tenant_id, limits, today, week, month]
      properties:
        tenant_id: { type: string, description: 'The project''s tenant id (the key authenticates a project''s tenant).' }
        limits:
          type: object
          description: 'The configured caps in micro-dollars; 0 = uncapped on that window.'
          properties:
            daily_micros: { type: integer, format: int64 }
            weekly_micros: { type: integer, format: int64 }
            monthly_micros: { type: integer, format: int64 }
        today:
          type: object
          description: 'Today''s spend against the effective daily ceiling (min of balance and caps).'
          properties:
            spent_micros: { type: integer, format: int64, description: 'Spent so far this UTC day.' }
            ceiling_micros: { type: integer, format: int64, description: 'Effective daily ceiling the gateway is enforcing right now.' }
            remaining_micros: { type: integer, format: int64, description: 'max(ceiling - spent, 0).' }
        week:
          $ref: '#/components/schemas/WindowSpend'
        month:
          $ref: '#/components/schemas/WindowSpend'
    WindowSpend:
      type: object
      description: 'One calendar-UTC window''s spend against its self-set cap: the week from Monday 00:00, the month from the 1st. On the same counters as `today`, so the figures agree to the micro-dollar.'
      required: [spent_micros, cap_micros]
      properties:
        spent_micros: { type: integer, format: int64, description: 'Spent so far in the window, today included.' }
        cap_micros: { type: integer, format: int64, description: 'The self-set cap on the window; 0 = none.' }
        remaining_micros: { type: integer, format: int64, description: 'max(cap - spent, 0). Present only when the window has a cap: without one it is bounded by the credit balance, which the gateway does not hold.' }

    # ---------- account, health, webhook ----------
    AccountResponse:
      type: object
      description: 'GET /v1/account: what the gateway knows about the calling key and tenant. The credit balance is not here (the gateway does not hold it); console_url is where it lives. Reference §6.3.'
      required: [tenant_id, key, rate, spend, caps, retention, tiers_allowed, default_tier, console_url]
      x-stability: frozen
      properties:
        tenant_id: { type: string }
        key:
          type: object
          description: 'The key that made this request. Never the secret or its hash.'
          required: [id]
          properties:
            id: { type: string }
            label: { type: string }
            scopes: { type: array, items: { type: string } }
            expires_at: { type: string, format: date-time, description: 'When the key stops authenticating: its own expiry when it has one (expires_in_days, or a rotated-out key in its overlap), else an unclaimed agent''s claim window. Absent when neither applies.' }
        rate:
          type: object
          description: 'The configured limits and both windows as they stand now — the same numbers as the X-RateLimit-* headers. A window with no limit (0) has no remaining/reset.'
          required: [per_minute, burst, per_hour, concurrent]
          properties:
            per_minute: { type: integer, description: '0 = no per-minute limit.' }
            burst: { type: integer, description: 'The per-minute bucket size.' }
            per_hour: { type: integer, description: '0 = no hourly limit.' }
            concurrent: { type: integer, description: 'max_concurrent; 0 = unlimited.' }
            remaining_minute: { type: integer, minimum: 0 }
            remaining_hour: { type: integer, minimum: 0 }
            reset_minute: { type: string, format: date-time, description: 'When the per-minute bucket is full again.' }
            reset_hour: { type: string, format: date-time }
        spend:
          type: object
          description: 'Today''s spend against the ENFORCED daily ceiling — the same figures as /v1/limits `today` — plus the week''s and the month''s spend on the same counters, and when each window starts over.'
          required: [daily_ceiling_micros, spent_today_micros, remaining_today_micros, credits_exhausted, spent_week_micros, spent_month_micros, day_resets_at, week_resets_at, month_resets_at]
          properties:
            daily_ceiling_micros: { type: integer, format: int64, description: '0 = no ceiling.' }
            spent_today_micros: { type: integer, format: int64 }
            remaining_today_micros: { type: integer, format: int64, description: 'max(ceiling - spent, 0). The one figure that is always there and always what will be refused next.' }
            credits_exhausted: { type: boolean, description: 'The control plane''s flag behind 402 credits_exhausted.' }
            spent_week_micros: { type: integer, format: int64, description: 'Spent since Monday 00:00 UTC, today included.' }
            spent_month_micros: { type: integer, format: int64, description: 'Spent since the 1st 00:00 UTC, today included.' }
            remaining_week_micros: { type: integer, format: int64, description: 'max(caps.weekly_micros - spent_week_micros, 0). Absent when the week has no self-set cap: it is then bounded only by the credit balance, which lives in the console.' }
            remaining_month_micros: { type: integer, format: int64, description: 'max(caps.monthly_micros - spent_month_micros, 0). Absent when the month has no self-set cap.' }
            day_resets_at: { type: string, format: date-time, description: 'The next UTC midnight: when today''s counter and the daily ceiling start over.' }
            week_resets_at: { type: string, format: date-time, description: 'The next Monday 00:00 UTC.' }
            month_resets_at: { type: string, format: date-time, description: 'The 1st of next month, 00:00 UTC.' }
        caps:
          type: object
          description: 'The self-set caps (0 = uncapped) — the same as /v1/limits `limits`.'
          required: [daily_micros, weekly_micros, monthly_micros]
          properties:
            daily_micros: { type: integer, format: int64 }
            weekly_micros: { type: integer, format: int64 }
            monthly_micros: { type: integer, format: int64 }
        retention:
          type: object
          required: [content, training_consent]
          properties:
            content: { type: boolean }
            training_consent: { type: boolean }
            ttl_days: { type: integer, description: 'Absent/0 = the platform default.' }
        tiers_allowed:
          type: array
          items: { $ref: '#/components/schemas/Tier' }
          description: 'The tiers this key may request: allowed_tiers, or all three.'
        default_tier: { $ref: '#/components/schemas/Tier' }
        console_url: { type: string, description: 'The console''s public address, where the balance is; "" on a deployment that set none.' }
    HealthResponse:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean, const: true }
      additionalProperties: false
      x-stability: frozen
    WebhookSlowLaneLanded:
      type: object
      description: >
        The one webhook event, POSTed to a project's configured URL (set in the
        console; PUT /internal/v1/projects/{id}/webhook, owner/admin) when a
        slow-lane arm lands after the response went out. Sent only after the arm
        is billed and the stored trace amended, so GET /v1/traces/{request_id}
        already shows it. Headers: X-Jevlin-Event, X-Jevlin-Timestamp (Unix
        seconds), X-Jevlin-Signature = "sha256=" + hex(HMAC-SHA256(secret,
        timestamp + "." + body)). Up to 4 attempts (backoff 1s/2s/4s, 5s timeout)
        on a transport error, 5xx, 408 or 429; any other 4xx ends the attempts.
        May arrive more than once; dedupe on request_id + provider. Carries no
        content. Reference §7.2.
      required: [event, request_id, tenant_id, provider, status, cost_micros, landed_at]
      x-stability: frozen
      properties:
        event: { type: string, const: search.slow_lane.landed }
        request_id: { type: string, format: uuid, description: 'The search''s request_id / trace id.' }
        tenant_id: { type: string }
        provider: { type: string, description: 'The arm that landed.' }
        status: { $ref: '#/components/schemas/CandidateStatus' }
        cost_micros: { type: integer, format: int64 }
        landed_at: { type: string, format: date-time }
