openapi: 3.1.0
info:
  title: AngleVerdict Public API
  version: 1.0.0
  description: |
    The programmatic `/v1` surface for AngleVerdict — compliance verdicts for
    ad creatives. API-key authenticated (operator-minted keys). Every error is
    returned as RFC 9457 Problem+JSON (`application/problem+json`); branch on
    the stable `code` field, never on `detail`/`status`.

    Authentication
    ==============
    Send the key in EITHER `X-API-Key: av_live_...` (preferred) OR
    `Authorization: Bearer av_live_...`. Keys are least-privilege scoped
    (`check:write` / `check:read` / `account:read`); a key with an EMPTY scope
    set has full access (legacy back-compat). The required scopes per
    operation are documented in each operation's description and the custom
    `x-required-scopes` extension.

    Rate limits
    ===========
    Each key has an independent per-key ingress budget sized by the tenant's
    tier (free 60/min, starter 600/min, scale 6000/min). On exhaustion the
    guard returns `429 auth.api_key_rate_limited` with `context.retryAfterMs`.
  contact:
    name: AngleVerdict
    email: hello@angleverdict.com

servers:
  - url: https://api.angleverdict.com
    description: Production
  - url: https://api-staging.angleverdict.com
    description: Staging

security:
  - apiKey: []
  - bearerAuth: []

tags:
  - name: account
    description: Account introspection
  - name: checks
    description: Compliance verdicts (create + read past verdicts)

paths:
  /v1/me:
    get:
      operationId: getMe
      summary: Introspect the authenticating API key
      description: |
        Returns the tenant + key identity and granted scopes of the
        authenticating API key. Proves authentication works.

        **Required scope:** `account:read`
      tags: [account]
      x-required-scopes: [account:read]
      responses:
        '200':
          description: The authenticating key's identity + scopes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
              example:
                tenant_id: 11111111-1111-1111-1111-111111111111
                api_key_id: ak_7f3c1d
                name: Production integration key
                scopes: [check:write, check:read, account:read]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/usage:
    get:
      operationId: getUsage
      summary: Introspect the key's tier + remaining rate-limit budget
      description: |
        Returns the authenticating key's rate-limit tier and the current
        per-key ingress budget (limit, window, remaining, reset time). The
        remaining/reset values reflect the SAME per-key bucket the guard
        enforces.

        FREE + non-consuming: this endpoint PEEKS the bucket without spending
        a token, so polling `/v1/usage` never affects your remaining budget.
        (The guard still consumes one token to admit the GET itself, exactly
        as for any authenticated `/v1` request, so `remaining` is the
        post-admission value.)

        **Required scope:** `account:read`
      tags: [account]
      x-required-scopes: [account:read]
      responses:
        '200':
          description: The key's tier + current per-key rate-limit budget.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
              example:
                tier: scale
                rate_limit:
                  limit: 6000
                  window_seconds: 60
                  remaining: 5987
                  reset_at: '2026-06-09T12:00:01.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/check:
    post:
      operationId: createCheck
      summary: Check a creative against compliance rules
      description: |
        Classifies `text` against the active policy packs for the caller's
        tenant and returns the verdict + findings. Exercises the same
        classifier as the interactive surface.

        Supply an `Idempotency-Key` header to make retries safe: a repeated
        call with the same key within 24h replays the cached verdict and sets
        the `Idempotent-Replayed: true` response header. On a multi-market
        request the key is scoped PER MARKET internally, so a retry can never
        serve one market's verdict as another's.

        **Multi-market.** Pass `jurisdictions` to judge one creative against
        several regulatory markets in a single call. Each market is a separate
        evaluation with its own pack and audit row, and **consumes one check
        from your monthly quota** — three markets cost three checks. Read
        `complete` before treating a `safe` verdict as a clearance.

        **Quota.** This endpoint is metered against the same monthly allowance
        as the interactive surface and returns `402` at the cap.

        **Required scope:** `check:write`
      tags: [checks]
      x-required-scopes: [check:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckRequest'
            example:
              text: Clinically proven to melt 20 pounds of belly fat in one week with no diet or exercise.
      responses:
        '200':
          description: The classification verdict.
          headers:
            Idempotent-Replayed:
              description: |
                Present and `true` when this response replays a verdict cached
                under the supplied `Idempotency-Key` (not a fresh classify).
              schema:
                type: string
                enum: ['true']
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckResponse'
              example:
                verdict: banned-claim
                findings:
                  - rule_id: ftc.weight-loss.fat-melt
                    severity: banned
                    rationale: Implies effortless rapid fat loss — an FTC "Gut Check" prohibited claim.
                    citation: FTC Act §5(a)
                    source_url: https://www.ftc.gov/legal-library/browse/statutes/federal-trade-commission-act
                policy_version: 2026.06.01
                classifier_version: hybrid-1.4.0
                confidence: 0.97
                vertical_id: nutra-us
                audit_id: 01HX7K4F0PZQ2T6PJ9N2W6QYRA
                complete: true
                jurisdictions:
                  - jurisdiction: us
                    status: judged
                    vertical_id: nutra-us
                    verdict: banned-claim
                    audit_id: 01HX7K4F0PZQ2T6PJ9N2W6QYRA
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/LlmDegraded'

  /v1/checks:
    get:
      operationId: listChecks
      summary: List past verdicts (keyset-paginated)
      description: |
        Returns the caller tenant's past verdicts, newest first, with opaque
        keyset (seek) cursor pagination. The tenant scope is taken from the
        API key — never from a query parameter.

        **Required scope:** `check:read`
      tags: [checks]
      x-required-scopes: [check:read]
      parameters:
        - name: limit
          in: query
          description: Page size. Default 25, capped at 100.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          description: |
            Opaque continuation token from a previous response's
            `next_cursor`. Treat it as a blob and echo it verbatim. A
            malformed/tampered token is treated as "first page" (fail-safe).
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 512
        - name: verdict
          in: query
          description: Optional filter over the public verdict vocabulary.
          required: false
          schema:
            $ref: '#/components/schemas/Verdict'
      responses:
        '200':
          description: A page of verdict summaries + the next cursor.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChecksListResponse'
              example:
                data:
                  - id: '3210'
                    verdict: banned-claim
                    confidence: 0.97
                    created_at: '2026-06-09T12:00:00.000Z'
                    vertical_id: ftc-supplements
                    snippet: Clinically proven to melt 20 pounds of belly fat…
                  - id: '3209'
                    verdict: safe
                    confidence: 1
                    created_at: '2026-06-09T11:58:00.000Z'
                    vertical_id: ftc-supplements
                    snippet: Supports your wellness routine.
                next_cursor: eyJjIjoiMjAyNi0wNi0wOVQxMTo1ODowMC4wMDBaIiwiaSI6IjMyMDkifQ
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/checks/{id}:
    get:
      operationId: getCheck
      summary: Get a single past verdict
      description: |
        Returns the full public detail for one past verdict by id. Tenant-
        scoped: an unknown id, an id belonging to another tenant, and a non-
        verdict audit row are all indistinguishable `404 v1.check_not_found`
        (no existence oracle).

        **Required scope:** `check:read`
      tags: [checks]
      x-required-scopes: [check:read]
      parameters:
        - name: id
          in: path
          required: true
          description: The verdict id (from a list-row `id` or a previous detail).
          schema:
            type: string
      responses:
        '200':
          description: The full public verdict detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicCheckDetail'
              example:
                id: '3210'
                verdict: banned-claim
                confidence: 0.97
                created_at: '2026-06-09T12:00:00.000Z'
                vertical_id: ftc-supplements
                snippet: Clinically proven to melt 20 pounds of belly fat…
                findings:
                  - rule_id: ftc.weight-loss.fat-melt
                    severity: banned
                    rationale: Implies effortless rapid fat loss — an FTC "Gut Check" prohibited claim.
                policy_version: 2026.06.01
                classifier_version: hybrid-1.4.0
                retained_until: '2027-06-09T12:00:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/CheckNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Operator-minted API key (`av_live_...`). Preferred channel.
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer av_live_...`. Only tokens carrying the `av_`
        brand prefix are routed to API-key auth (so a JWT bearer never clashes).

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Opaque client-supplied key. A repeated POST /v1/check with the same
        key within 24h replays the cached verdict (and sets the
        `Idempotent-Replayed: true` response header). Empty/whitespace-only is
        ignored.
      schema:
        type: string

  responses:
    Unauthorized:
      description: Missing, invalid, or revoked API key (`auth.api_key_invalid`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/auth.api_key_invalid
            title: Api Key Invalid
            status: 401
            detail: API key missing or invalid
            code: auth.api_key_invalid
            instance: /v1/me
            request_id: 7f3c1d2e-0000-4000-8000-000000000000
    PaymentRequired:
      description: |
        The tenant's monthly check allowance is exhausted (`filter.quota_exceeded`), or the
        request's market count would exceed what is left. `context` carries `tier`,
        `current_usage`, `hard_cap` and — on a multi-market request — `requested_cost`.

        A multi-market check consumes ONE CHECK PER MARKET, so a request for three markets is
        refused when fewer than three remain rather than partially served.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/filter.quota_exceeded
            title: Quota Exceeded
            status: 402
            detail: Monthly check quota reached for this plan. Upgrade or contact us to raise the cap.
            code: filter.quota_exceeded
            instance: /v1/check
            request_id: 7f3c1d2e-0000-4000-8000-000000000000
    Forbidden:
      description: |
        The key lacks a scope the operation requires
        (`auth.api_key_insufficient_scope`). `context` carries `required` +
        `granted`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/auth.api_key_insufficient_scope
            title: Api Key Insufficient Scope
            status: 403
            detail: API key lacks the scope(s) required for this operation
            code: auth.api_key_insufficient_scope
            instance: /v1/check
            request_id: 7f3c1d2e-0000-4000-8000-000000000001
            context:
              required: [check:write]
              granted: [check:read]
    UnprocessableEntity:
      description: Request body / query failed validation (`validation.failed`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/validation.failed
            title: Validation Failed
            status: 422
            detail: text must contain at least 12 character(s)
            code: validation.failed
            instance: /v1/check
            request_id: 7f3c1d2e-0000-4000-8000-000000000002
    RateLimited:
      description: |
        Per-key ingress rate limit exceeded (`auth.api_key_rate_limited`), or
        the downstream per-tenant filter limit (`v1.rate_limited`). The delay
        until the next token is in `context.retryAfterMs` /
        `context.retry_after_ms`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/auth.api_key_rate_limited
            title: Api Key Rate Limited
            status: 429
            detail: API key rate limit exceeded; retry after the indicated delay
            code: auth.api_key_rate_limited
            instance: /v1/check
            request_id: 7f3c1d2e-0000-4000-8000-000000000003
            context:
              retryAfterMs: 1500
    LlmDegraded:
      description: Upstream LLM degraded; retry later (`v1.llm_degraded`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/v1.llm_degraded
            title: Llm Degraded
            status: 503
            detail: request could not be completed (llm_degraded)
            code: v1.llm_degraded
            instance: /v1/check
            request_id: 7f3c1d2e-0000-4000-8000-000000000004
            context:
              retry_after_ms: 5000
    CheckNotFound:
      description: |
        No verdict with that id for the caller's tenant
        (`v1.check_not_found`). Also covers cross-tenant ids and non-verdict
        rows — indistinguishable from "unknown".
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemJson'
          example:
            type: https://errors.angleverdict.com/v1.check_not_found
            title: Check Not Found
            status: 404
            detail: No verdict found with id "3210"
            code: v1.check_not_found
            instance: /v1/checks/3210
            request_id: 7f3c1d2e-0000-4000-8000-000000000005

  schemas:
    Verdict:
      type: string
      description: |
        The public verdict vocabulary, emitted 1:1 by the classifier
        (`VerdictKind`).
      enum:
        - safe
        - risky-flagged
        - banned-claim

    Finding:
      type: object
      description: A single rule finding contributing to a verdict.
      required: [rule_id, severity, rationale]
      additionalProperties: false
      properties:
        rule_id:
          type: string
          description: Stable id of the policy rule that fired.
        severity:
          type: string
          description: Finding severity.
          enum: [banned, risky, info]
        rationale:
          type: string
          description: Human-readable explanation of why the rule fired.
        citation:
          type: string
          description: >-
            The provision this rule applies, e.g. "16 CFR 255.5(a)". Added
            2026-08-30. OPTIONAL: absent means no instrument claims the rule
            yet, which is different from an empty citation. On GET
            /v1/checks/{id} this is the provision recorded WHEN THE VERDICT WAS
            MADE, not whatever the rule cites today.
          example: 16 CFR 255.5(a)
        source_url:
          type: string
          format: uri
          description: >-
            Link to the published text of `citation`. Present only when the
            provision has a stable public URL — the packs are agent-authored and
            not attorney-signed, so being able to read the law and judge for
            yourself is part of the product.
          example: https://www.ecfr.gov/current/title-16/part-255#p-255.5(a)

    MeResponse:
      type: object
      description: Identity + scopes of the authenticating API key.
      required: [tenant_id, api_key_id, name, scopes]
      additionalProperties: false
      properties:
        tenant_id:
          type: string
          description: The tenant the key belongs to.
        api_key_id:
          type: string
          description: The key's id.
        name:
          type: string
          description: Human label of the key.
        scopes:
          type: array
          description: Granted scopes (empty = full access).
          items:
            type: string
            enum: [check:write, check:read, account:read]

    UsageResponse:
      type: object
      description: |
        Per-key rate-limit usage for GET /v1/usage. The values mirror the
        per-key ingress bucket the ApiKeyGuard enforces; reading them is free
        (non-consuming peek).
      required: [tier, rate_limit]
      additionalProperties: false
      properties:
        tier:
          type: string
          description: |
            The resolved rate-limit tier (`free` / `starter` / `scale`), or
            `default` when the tenant has no tier set (→ the 600/min default
            bucket, legacy flat behaviour).
        rate_limit:
          type: object
          required: [limit, window_seconds, remaining, reset_at]
          additionalProperties: false
          properties:
            limit:
              type: integer
              description: Bucket capacity — max requests allowed per window.
              minimum: 1
            window_seconds:
              type: integer
              description: Window length in seconds.
              minimum: 1
            remaining:
              type: integer
              description: Tokens currently left in the bucket (post-admission).
              minimum: 0
            reset_at:
              type: string
              format: date-time
              description: When the bucket fully refills (ISO-8601).

    CheckRequest:
      type: object
      description: |
        Body for POST /v1/check.

        Every field except `text` is OPTIONAL and ADDITIVE (2026-08-13): a client that has
        always sent `{ "text": "..." }` is unaffected. What changed for strict clients is
        that `additionalProperties: false` now admits four more names.

        Before these fields existed, the pack was resolved server-side from the tenant's
        settings, so an API caller could not choose what their creative was judged against —
        and a vertical with no pinned pack was silently judged under the default nutra pack.
      required: [text]
      additionalProperties: false
      properties:
        text:
          type: string
          description: The creative text to classify.
          minLength: 12
          maxLength: 5000
        vertical_id:
          type: string
          description: |
            Explicit policy pack. Entitlement-checked: `403` when the tenant is not entitled,
            `404` when the id is not in the catalog. Both the canonical 4D form
            (`meta-nutra-us`) and the legacy form (`nutra-us`) are accepted and normalise to
            the same pack. Omitted → the tenant's default pack.
          pattern: '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'
        vertical:
          type: string
          description: |
            Vertical hint. Omitted → the tenant's configured default → the catalog default
            (`nutra`). A vertical with no active policy pack is refused with `422` rather than
            evaluated under the default pack, because a confident wrong-frame verdict (a crypto
            ad judged by nutra rules) is worse than an explicit gap.
        jurisdiction:
          $ref: '#/components/schemas/Jurisdiction'
        platform:
          type: string
          description: |
            The ad platform (default `meta`). A platform is a request dimension, not a pack id:
            a `tiktok` check is judged by TikTok's policy layer on the same underlying law.
            An unsupported platform is refused with `422`.
        jurisdictions:
          type: array
          description: |
            MULTI-market selection. Each market is a SEPARATE evaluation with its own policy
            pack, its own audit row and its own verdict, and each consumes one check from the
            monthly quota — exactly as the interactive surface counts it.

            A market with no grounded pack for the requested vertical is reported as
            `not_covered` in the response rather than substituted; when EVERY requested market
            is ungrounded the request is refused with `422`.

            Merges with the singular `jurisdiction` when both are sent. Requesting more than
            the maximum is refused rather than truncated: a silent truncation would read as
            full coverage.
          minItems: 1
          maxItems: 4
          items:
            $ref: '#/components/schemas/Jurisdiction'

    Jurisdiction:
      type: string
      description: A regulatory market. `us` is the default when none is specified.
      enum: [us, eu, de, uk, fr, es, it, nl]

    JurisdictionOutcome:
      type: object
      description: |
        One market's own answer within a check.

        `status` is the whole contract:
          * `judged` — a real verdict for this market, with its own pack and audit row.
          * `not_covered` — no grounded pack for this (vertical × market) cell. NOT an error,
            and explicitly NOT a pass.
          * `error` — this market's evaluation failed on its own (rate limit, LLM unavailable)
            while others may have succeeded. Failures are isolated per market by design: one
            429 flags one market, not every selected one.
      required: [jurisdiction, status]
      additionalProperties: false
      properties:
        jurisdiction:
          $ref: '#/components/schemas/Jurisdiction'
        status:
          type: string
          enum: [judged, not_covered, error]
        vertical_id:
          type: string
          description: The pack whose rules actually ran for this market.
        via_baseline:
          $ref: '#/components/schemas/Jurisdiction'
        verdict:
          $ref: '#/components/schemas/Verdict'
        findings:
          type: array
          items:
            $ref: '#/components/schemas/Finding'
        audit_id:
          type: string
          description: This market's own audit-row id.
        reason:
          type: string
          description: Present on `not_covered` / `error` — why.

    CheckResponse:
      type: object
      description: |
        The classification verdict returned by POST /v1/check.

        The TOP-LEVEL fields describe the WORST-CASE market among those judged, not the first
        one requested: a client that reads only the top level must never see a pass it does not
        have. `jurisdictions` is authoritative for per-market detail.
      required:
        [verdict, findings, policy_version, classifier_version, confidence, vertical_id,
         audit_id, jurisdictions, complete]
      additionalProperties: false
      properties:
        verdict:
          $ref: '#/components/schemas/Verdict'
        findings:
          type: array
          items:
            $ref: '#/components/schemas/Finding'
        policy_version:
          type: string
          description: Version of the policy pack set applied.
        classifier_version:
          type: string
          description: Version of the classifier that produced the verdict.
        confidence:
          type: [number, 'null']
          description: |
            Classifier confidence in [0, 1], or null for rule-based verdicts
            with no LLM confidence.
          minimum: 0
          maximum: 1
        vertical_id:
          type: string
          description: |
            The pack whose rules actually ran. Added 2026-08-13 — without it a caller could not
            distinguish a verdict from their intended pack from one silently produced by the
            default pack.
        audit_id:
          type: string
          description: The audit-row id for the worst-case market; use with GET /v1/checks/{id}.
        jurisdictions:
          type: array
          description: |
            Every REQUESTED market, in the order asked for, including the ones we could not
            judge. Always present — one entry for a single-market check — so clients read one
            shape rather than two.
          minItems: 1
          items:
            $ref: '#/components/schemas/JurisdictionOutcome'
        complete:
          type: boolean
          description: |
            `false` when ANY requested market went unjudged (`not_covered` or `error`).

            GATE YOUR "cleared" RENDERING ON THIS, not on `verdict` alone. `verdict: safe` with
            `complete: false` means "clear in the markets we judged" — treating it as "clear
            everywhere" would report a clearance for a market that was never examined.
        rejected_jurisdictions:
          type: array
          description: |
            Markets the caller NAMED that we do not recognise. Reported rather than dropped:
            an unrecognised market is a client bug and must not be mistaken for coverage.
          items:
            type: object
            required: [value, reason]
            additionalProperties: false
            properties:
              value:
                type: string
              reason:
                type: string
                enum: [invalid, over-cap]

    PublicCheckSummary:
      type: object
      description: List-row projection for GET /v1/checks (non-sensitive subset).
      required: [id, verdict, confidence, created_at, vertical_id, snippet]
      additionalProperties: false
      properties:
        id:
          type: string
          description: The verdict id (use as the path id for GET /v1/checks/:id).
        verdict:
          type: string
          description: The stored verdict label (classifier vocabulary).
        confidence:
          type: number
          description: Confidence in [0, 1]; 1 for rule-based rows.
          minimum: 0
          maximum: 1
        created_at:
          type: string
          format: date-time
          description: When the verdict was produced (ISO-8601).
        vertical_id:
          type: string
          description: The policy pack that produced the verdict.
        snippet:
          type: string
          description: PII-redacted excerpt (≤120 chars) of the checked text.

    PublicCheckDetail:
      type: object
      description: Full public detail for GET /v1/checks/:id.
      required:
        - id
        - verdict
        - confidence
        - created_at
        - vertical_id
        - snippet
        - findings
        - policy_version
        - classifier_version
        - retained_until
      additionalProperties: false
      properties:
        id:
          type: string
        verdict:
          type: string
        confidence:
          type: number
          minimum: 0
          maximum: 1
        created_at:
          type: string
          format: date-time
        vertical_id:
          type: string
        snippet:
          type: string
        findings:
          type: array
          items:
            $ref: '#/components/schemas/Finding'
        policy_version:
          type: string
        classifier_version:
          type: string
        retained_until:
          type: string
          format: date-time
          description: When this verdict row is scheduled for deletion (ISO-8601).

    ChecksListResponse:
      type: object
      description: A page of verdict summaries.
      required: [data, next_cursor]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/PublicCheckSummary'
        next_cursor:
          type: [string, 'null']
          description: |
            Opaque cursor for the next page, or null when there are no more
            rows. Echo it back verbatim in the `cursor` query parameter.

    ProblemJson:
      type: object
      description: RFC 9457 Problem+JSON error envelope. Branch on `code`.
      required: [type, title, status, detail, code]
      additionalProperties: true
      properties:
        type:
          type: string
          format: uri
          description: '`${base}/{code}` — dereferenceable error type URI.'
        title:
          type: string
          description: Title-cased from `code`.
        status:
          type: integer
          description: HTTP status (mirrors the response status line).
        detail:
          type: string
          description: Safe-to-expose human message — never internal/PII.
        code:
          type: string
          description: The stable machine key. Branch on this, not detail/status.
        instance:
          type: string
          description: The request path (when known).
        request_id:
          type: string
          description: Correlation id — echo to support to find server logs.
        context:
          type: object
          description: Machine-readable structured data (e.g. retryAfterMs).
          additionalProperties: true
