openapi: 3.1.0
info:
  title: lookup.disclose.io
  summary: Security contact attribution for vulnerability disclosure.
  description: |
    Identify asset owners and find security reporting channels for responsible
    vulnerability disclosure across 16 input types (domain, IP, URL, email,
    ASN, CIDR, package, repository, container, cloud resource, mobile app,
    hardware, extension, desktop app, organization).

    Cross-strategy resolution chains up to 3 hops deep — a package lookup
    can chain to its repository, which chains to the organization's domain,
    which finds `security.txt`.

    ## Conventions

    - **All endpoints are CORS-open and unauthenticated.** An optional API
      key is planned (see roadmap section below).
    - **Every response carries an `X-Request-Id` header.** Server-generated
      as `req_<uuid>` unless the caller supplied a well-formed inbound
      `X-Request-Id` (matching `^[A-Za-z0-9._:-]{1,128}$`). Quote this ID
      in support tickets — it is also recorded in the persistent request
      log and surfaced inside successful `/api/lookup` response bodies as
      `requestId`.
    - **Every non-2xx response is a JSON `ErrorEnvelope`.** Clients can
      branch on the `status` field regardless of HTTP status code.
    - **The `status` enum is stable.** See the `ResponseStatus` schema.
      Engine emits `complete | partial | failed`; the server adds
      `rate_limited | not_found | error` for non-2xx paths.

    ## Contact verification

    `ContactChannel.verified: boolean` distinguishes contact records
    derived from authoritative sources (`security.txt`, SECURITY.md,
    DioDB, PSIRT directories) from heuristic guesses such as the
    `security@`/`abuse@` convention emails generated at depth 0 when no
    other channel exists. Heuristic contacts carry `verified: false` and
    `source: "convention-email"`. Coordinators should treat
    `verified: false` contacts as candidates requiring confirmation.

    A future release will replace the boolean with a richer
    `verificationMethod` enum and add per-contact `evidence.url` +
    `fetchedAt` so consumers can render a "click to verify source"
    affordance. The `verified` field will remain for backward
    compatibility.

    ## Rate limits

    In-memory token-bucket per remote IP + path. Headers from the IETF
    draft `draft-ietf-httpapi-ratelimit-headers` are emitted on every
    limited response (success and 429):

    - `RateLimit-Limit` — bucket capacity (= max burst).
    - `RateLimit-Remaining` — whole tokens left after this request.
    - `RateLimit-Reset` — seconds until the bucket is fully refilled.
    - `Retry-After` — seconds to wait before retrying (429 only).

    Per-endpoint budgets, all per-IP:

    | Endpoint              | Limit | Window |
    | --------------------- | ----- | ------ |
    | `POST /api/lookup`    | 30    | 60s    |
    | `POST /api/feedback`  | 10    | 60s    |
    | `POST /mcp`           | 30    | 60s    |
    | `GET /stats`          | 60    | 60s    |
    | `GET /healthz`        | unlimited | — |
    | `GET /openapi.yaml`   | unlimited | — |
    | `GET /api-docs`       | unlimited | — |

    ## Caching

    `POST /api/lookup` responses are cached server-side for 6 hours,
    keyed on the normalized input. Each response carries a weak `ETag`
    derived from the response body. Clients should send `If-None-Match`
    with the last seen ETag to short-circuit unchanged responses:

    - **Match**: server returns `304 Not Modified` with `ETag`,
      `Cache-Control`, `X-Request-Id`, and the `RateLimit-*` headers —
      no body. The cached body is unchanged.
    - **No match (or no ETag supplied)**: full `200` response with `ETag`
      and `Cache-Control: public, max-age=900, stale-while-revalidate=3600`.

    ## Partial-result reporting

    A `complete` status historically meant "we returned at least one strong
    contact." It did *not* tell you whether one of the underlying steps
    crashed silently along the way. Two response fields close that gap:

    - **`hasErrors`** on `LookupResult` is `true` when at least one
      resolution step (in this strategy or any chained child strategy)
      either threw an exception OR returned an internal `details.error`.
      Consumers should render a "results may be incomplete" badge when
      `hasErrors === true`, regardless of `status`.
    - **`dataSources[].error`** carries the per-step error message. Its
      presence forces `confidence: 0` on that entry. Greppable server
      logs are emitted as `[<step-name>] WARN <message>`.

    ## Roadmap

    Public roadmap items still in flight, in order of expected delivery:

    1. ~~`Cache-Control` + `ETag` on `/api/lookup`~~ — shipped.
    2. ~~RFC `RateLimit-*` headers + `Retry-After` on 429~~ — shipped.
    3. `GET /api/normalize`, `GET /api/supported-ecosystems`, extended
       `POST /api/feedback` with `lookupId` + `outcome`.
    4. Per-contact `evidence` + `verificationMethod`.
    5. Optional API key + per-key quota (unlocks MCP transport for
       downstream MCP servers).
    6. `?deadline=ms` upper-bound parameter on `/api/lookup`.
  version: 2.1.0
  license:
    name: MIT
    identifier: MIT
  contact:
    name: disclose.io
    url: https://disclose.io
servers:
  - url: https://lookup.disclose.io
    description: Production
  - url: http://localhost:3000
    description: Local development
tags:
  - name: lookup
    description: Primary attribution + contact lookup
  - name: feedback
    description: User feedback on lookup quality
  - name: health
    description: Operational health probes
  - name: stats
    description: Aggregated lookup metrics (unlisted)
  - name: mcp
    description: Model Context Protocol transport
  - name: docs
    description: API documentation surface
paths:
  /api/lookup:
    post:
      tags: [lookup]
      summary: Run a security attribution lookup
      description: |
        Classifies the input, dispatches it to the appropriate strategy,
        and runs cross-strategy chaining (max depth 3). Returns
        attribution, contacts (sorted by confidence then type priority),
        the resolution chain, and the list of data sources queried.

        Inputs are auto-classified. Use prefix forms to disambiguate
        (`npm:foo`, `pypi:bar`, `gh:owner/repo`, `app:WhatsApp`,
        `hw:Cisco ASA`, `ext:uBlock Origin`, `desktop:Slack`). Bare
        organization names fall through to the Organization strategy.

        **Attack-payload short-circuit.** Inputs matching SQL-injection
        and XSS patterns return `status: "failed"` immediately without
        running the strategy chain, so abuse traffic does not inflate
        partial-result metrics.
      operationId: lookup
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
        - $ref: '#/components/parameters/IfNoneMatch'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LookupRequest'
            examples:
              domain:
                value: { input: cloudflare.com }
              package:
                value: { input: 'npm:express' }
              repository:
                value: { input: 'https://github.com/expressjs/express' }
              mobileApp:
                value: { input: 'app:WhatsApp' }
              hardware:
                value: { input: 'hw:Cisco ASA 5505' }
      responses:
        '200':
          description: Lookup completed (status discriminates outcome).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/CacheControlLookup'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LookupResult'
        '304':
          description: |
            Caller's `If-None-Match` matches the current cached ETag.
            No body. Headers carry the unchanged ETag plus rate-limit info.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/CacheControlLookup'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/MissingInput'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/feedback:
    post:
      tags: [feedback]
      summary: Submit user feedback on a recent lookup
      description: |
        Append a free-form feedback record to the persistent feedback
        log. Used by the web UI's thumbs-up/down widget. A future release
        will extend this with `lookupId` + `contactValue` + `outcome` for
        structured contact-quality signal.
      operationId: submitFeedback
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FeedbackRequest'
      responses:
        '200':
          description: Feedback recorded.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeedbackResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /healthz:
    get:
      tags: [health]
      summary: Operational health probe
      description: Returns `ok` if the server is alive. Used by Fly.io health checks.
      operationId: healthz
      responses:
        '200':
          description: Service is healthy.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            text/plain:
              schema:
                type: string
                const: ok
  /healthz/bounty:
    get:
      tags: [health]
      summary: Bounty-program data-quality probe
      description: |
        Inspects the on-disk cache of bug bounty programs (Chaos + VDP)
        without re-fetching upstream. Returns 200 when the cached
        program count is at or above the floor; 503 when the count has
        dropped below the floor, which indicates an upstream-drift or
        fetch-failure regression that would silently strip Bugcrowd /
        HackerOne contacts from every lookup.
      operationId: healthzBounty
      responses:
        '200':
          description: Bounty cache is healthy.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BountyHealth'
              example:
                healthy: true
                count: 1726
                floor: 100
                lastUpdated: '2026-05-13T05:00:00.000Z'
                ageHours: 1.2
        '503':
          description: Bounty cache below floor — data quality degraded.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BountyHealth'
              example:
                healthy: false
                count: 0
                floor: 100
                lastUpdated: null
                ageHours: null
  /stats:
    get:
      tags: [stats]
      summary: Aggregated lookup metrics (unlisted)
      description: |
        JSON dump of totals, per-path/MCP breakdowns, lookup latency
        p50/p95/p99, top assets/user-agents/IPs, and recent errors.
        Unlisted endpoint — not linked from the UI; emits
        `X-Robots-Tag: noindex, nofollow, noarchive`.
      operationId: stats
      responses:
        '200':
          description: Stats snapshot.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatsSnapshot'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /mcp:
    post:
      tags: [mcp]
      summary: Model Context Protocol streamable-HTTP transport
      description: |
        Exposes `lookup_security_contact` and supporting tools as an MCP
        server over streamable HTTP per the Model Context Protocol spec.
        Stateless mode. No authentication today. See
        https://modelcontextprotocol.io for the wire protocol.
      operationId: mcp
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: JSON-RPC 2.0 envelope per the MCP spec.
              additionalProperties: true
      responses:
        '200':
          description: MCP response (streamable).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
            text/event-stream:
              schema:
                type: string
                description: Server-sent events for streamed responses.
        '429':
          $ref: '#/components/responses/RateLimited'
  /openapi.yaml:
    get:
      tags: [docs]
      summary: This OpenAPI 3.1 specification
      operationId: openapiYaml
      responses:
        '200':
          description: The spec itself.
          content:
            text/yaml:
              schema:
                type: string
  /api-docs:
    get:
      tags: [docs]
      summary: Interactive Swagger UI for this API
      operationId: apiDocs
      responses:
        '200':
          description: Swagger UI HTML.
          content:
            text/html:
              schema:
                type: string
components:
  parameters:
    InboundRequestId:
      name: X-Request-Id
      in: header
      required: false
      description: |
        Caller-supplied request identifier. Must match
        `^[A-Za-z0-9._:-]+$` and be at most 128 characters. Replaced with
        a server-generated `req_<uuid>` if absent or malformed. Echoed on
        every response.
      schema:
        type: string
        maxLength: 128
        pattern: '^[A-Za-z0-9._:\-]+$'
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: |
        Comma-separated list of weak ETags. If any matches the current
        cached ETag, the server returns 304 with no body. Use the `ETag`
        from a previous 200 response.
      schema:
        type: string
  headers:
    XRequestId:
      description: Server-generated or caller-supplied request identifier.
      schema:
        type: string
      required: true
    ETag:
      description: |
        Weak ETag derived from the response body (excluding per-request
        fields). Stable across repeat lookups while the underlying
        attribution + contacts are unchanged.
      schema:
        type: string
        example: 'W/"a1b2c3d4e5"'
    CacheControlLookup:
      description: |
        `public, max-age=900, stale-while-revalidate=3600`. Lookup
        responses are safe to cache by intermediaries for 15 minutes and
        served stale-while-revalidate for up to an hour.
      schema:
        type: string
        example: 'public, max-age=900, stale-while-revalidate=3600'
    RateLimitLimit:
      description: Bucket capacity (max burst) for this endpoint + caller.
      schema:
        type: integer
        minimum: 1
    RateLimitRemaining:
      description: Whole tokens remaining after this request.
      schema:
        type: integer
        minimum: 0
    RateLimitReset:
      description: Seconds until the bucket is fully refilled.
      schema:
        type: integer
        minimum: 0
  responses:
    MissingInput:
      description: Required `input` field missing or empty.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            errorMessage: Missing input field
            errorCode: missing-input
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    NotFound:
      description: No route handles this method + path.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: not_found
            errorMessage: No route for GET /no-such-thing
            errorCode: route-not-found
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    RateLimited:
      description: |
        Per-IP request budget exceeded. Response carries the canonical
        RFC `RateLimit-*` headers plus `Retry-After`.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
        Retry-After:
          description: Seconds until the next request is allowed.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: rate_limited
            errorMessage: 'Rate limit exceeded for /api/lookup — 30 requests per 60 seconds per IP'
            errorCode: rate-limited
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    InternalError:
      description: Unhandled server error.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    ResponseStatus:
      type: string
      description: |
        Status discriminator emitted on every API response (success and
        error). Stable contract — clients can switch on this field
        regardless of HTTP status code.
      enum:
        - complete
        - partial
        - failed
        - rate_limited
        - not_found
        - error
      x-enum-descriptions:
        complete: 200 — lookup found at least one verified reporting channel.
        partial: 200 — lookup found only fallback channels (abuse/convention/CERT).
        failed: 200 — no contacts found OR attack-payload short-circuit.
        rate_limited: 429 — request rate limit exceeded.
        not_found: 404 — no route handles this method + path.
        error: 4xx/5xx — bad input or server error.
    LookupStatus:
      type: string
      description: Subset of ResponseStatus emitted by the lookup engine on 200 responses.
      enum: [complete, partial, failed]
    AssetType:
      type: string
      enum:
        - domain
        - ipv4
        - ipv6
        - url
        - email
        - cidr
        - asn
        - package
        - repository
        - container
        - cloud-resource
        - mobile-app
        - hardware
        - extension
        - desktop-app
        - organization
    Confidence:
      type: string
      enum: [high, medium, low]
    ContactType:
      type: string
      enum:
        - bug_bounty
        - security_txt
        - dns_security_txt
        - vdp
        - email
        - abuse_contact
        - web_form
        - psirt
        - cert
        - convention
    ChainRelation:
      type: string
      description: |
        Relation of a cross-strategy chain. High-trust relations
        (`manufacturer`, `developer`, `platform_verified`) carry org
        names from curated user input and exempt the chain from the
        depth-0 lexical-inference guard.
      enum:
        - parent_company
        - parent_company_domain
        - subsidiary_domain
        - brand_domain
        - weak_inference
        - canonical_alias
        - related
        - manufacturer
        - developer
        - platform_verified
    ContactChannel:
      type: object
      required: [type, value, confidence, source, label, verified]
      properties:
        type:
          $ref: '#/components/schemas/ContactType'
        value:
          type: string
          description: The contact channel — email address, URL, or platform handle.
        confidence:
          $ref: '#/components/schemas/Confidence'
        source:
          type: string
          description: |
            Source step that produced this contact (e.g. `security-txt-apex`,
            `convention-email`, `diodb`, `github-security-md`).
        label:
          type: string
          description: Human-readable description of the contact's role.
        verified:
          type: boolean
          description: |
            **`true`** for contacts derived from authoritative sources
            (security.txt, SECURITY.md, DioDB, PSIRT directories).
            **`false`** for heuristic guesses (convention emails like
            `security@`/`abuse@` generated when no other channel exists).
            Coordinators should treat `verified: false` contacts as
            candidates requiring confirmation before disclosure outreach.
    Attribution:
      type: object
      required: [confidence]
      properties:
        organization:
          type: string
        jurisdiction:
          type: string
          description: Two-letter country code or jurisdiction name.
        industry:
          type: string
        confidence:
          $ref: '#/components/schemas/Confidence'
        relatedRanges:
          type: array
          items:
            type: string
        parentCompany:
          type: string
    DataSource:
      type: object
      required: [name, queried]
      properties:
        name:
          type: string
        queried:
          type: boolean
        timestamp:
          type: string
          format: date-time
        confidence:
          type: number
        error:
          type: string
          description: |
            Set when the step threw an exception OR returned an
            internal `details.error`. Surfaces "tried but failed"
            distinctly from "found nothing." Confidence is forced
            to 0 when this field is present. See `LookupResult.hasErrors`
            for a single boolean roll-up.
    ChainEdge:
      type: object
      required: [from, to, reason]
      properties:
        from:
          type: string
        to:
          type: string
        reason:
          type: string
        relation:
          $ref: '#/components/schemas/ChainRelation'
    LookupRequest:
      type: object
      required: [input]
      properties:
        input:
          type: string
          description: |
            The asset to look up. Auto-classified unless `kind` is
            supplied. Use prefix forms (`npm:`, `pypi:`, `gh:`, `app:`,
            `hw:`, `ext:`, `desktop:`) to disambiguate.
          minLength: 1
          maxLength: 1024
        kind:
          $ref: '#/components/schemas/AssetType'
          description: Optional — short-circuit classification by naming the asset type.
    LookupResult:
      type: object
      required: [input, assetType, timestamp, status, requestId, hasErrors, attribution, contacts, details, dataSources, chains]
      properties:
        input:
          type: string
        assetType:
          $ref: '#/components/schemas/AssetType'
        timestamp:
          type: string
          format: date-time
        status:
          $ref: '#/components/schemas/LookupStatus'
        requestId:
          type: string
          description: Same value emitted in the `X-Request-Id` response header.
        hasErrors:
          type: boolean
          description: |
            `true` when at least one resolution step (in this strategy
            or any chained child strategy) recorded an error — either
            by throwing or by returning `details.error`. Lets consumers
            render a "results may be incomplete" badge on a `complete`
            status whose primary-source step actually crashed.
            Per-step error detail is on `dataSources[].error`.
        attribution:
          $ref: '#/components/schemas/Attribution'
        contacts:
          type: array
          items:
            $ref: '#/components/schemas/ContactChannel'
        details:
          type: object
          additionalProperties: true
          description: |
            Free-form per-step diagnostic detail. When the engine short-circuits on a
            recognized-but-non-routable input (RFC1918 private space, loopback, multicast,
            RFC2606 documentation domains, mDNS .local, etc.) the response carries
            `status: failed` and `details` of the shape:
              reason: "reserved"
              category: rfc1918 | loopback | link_local | multicast | cgnat | unspecified
                      | ipv6_ula | rfc2606_tld | mdns_local | private_dns | ipv6_docs
                      | test_net | benchmarking | reserved_future
              rfc: e.g. "RFC1918"
              voice: short UI-facing line (UI surfaces this; programmatic clients ignore)
              explanation: one-line factual description
              suggestion: try-something-else hint
            When the engine rejects an obvious attack payload, `details.reason` is
            `"invalid-input"` with `kind` and `matchedPattern` keys instead.
        dataSources:
          type: array
          items:
            $ref: '#/components/schemas/DataSource'
        chains:
          type: array
          items:
            $ref: '#/components/schemas/ChainEdge'
    ErrorEnvelope:
      type: object
      required: [status, errorMessage, requestId]
      properties:
        status:
          type: string
          enum: [rate_limited, not_found, error]
        errorMessage:
          type: string
        requestId:
          type: string
        errorCode:
          type: string
          description: Optional machine-readable code for programmatic handling.
    FeedbackRequest:
      type: object
      properties:
        query:
          type: string
        assetType:
          type: string
        rating:
          type: string
        comment:
          type: string
          maxLength: 4096
        contactCount:
          type: integer
          minimum: 0
    FeedbackResponse:
      type: object
      required: [ok, requestId]
      properties:
        ok:
          type: boolean
          const: true
        requestId:
          type: string
    StatsSnapshot:
      type: object
      description: |
        Aggregated metrics dump. Top-level shape stable; nested fields
        evolve. See `server.ts:computeStats` for the current schema.
      additionalProperties: true
      properties:
        generatedAt:
          type: string
          format: date-time
        totals:
          type: object
          additionalProperties: true
        byPath:
          type: object
          additionalProperties: true
        lookups:
          type: object
          additionalProperties: true
    BountyHealth:
      type: object
      required: [healthy, count, floor, lastUpdated, ageHours]
      properties:
        healthy:
          type: boolean
          description: True when `count >= floor`.
        count:
          type: integer
          minimum: 0
          description: Programs currently in the merged Chaos + VDP cache.
        floor:
          type: integer
          minimum: 0
          description: Minimum healthy count (`BOUNTY_PROGRAM_FLOOR`, default 100).
        lastUpdated:
          type: [string, 'null']
          format: date-time
          description: ISO-8601 timestamp from the cache file, or null if no cache exists.
        ageHours:
          type: [number, 'null']
          description: Hours since the cache file was last written, or null if no cache.
