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, IPv4, IPv6, URL, email,
    ASN, CIDR, package, repository, container, cloud resource, mobile app,
    hardware, extension, desktop app, organization).

    For the highest-fidelity result, submit the exact affected asset. If it
    does not resolve, try another identifier for the same target; use an
    organization name as a fallback.

    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

    - **Core public endpoints are CORS-open for anonymous browser requests.**
      `POST /api/lookup`, `GET /api/lookup/progress`, `POST /api/feedback`,
      `POST /mcp`, health probes, and documentation routes work without a key.
      The experimental `POST /api/evidence` route is also anonymous and
      CORS-open in the production deployment, but self-hosted deployments can
      disable it or require a key. Supplying a valid API key
      from a server-side or same-origin client — either `Authorization: Bearer
      <key>` or `X-API-Key: <key>` — selects the documented keyed policy and
      tags the request for per-key analytics. Core endpoint quotas increase;
      evidence retains lower outbound-collection quotas. The current
      cross-origin preflight allow-list does not include those two
      authentication headers, so
      cross-origin browser callers use the anonymous tier. On public endpoints
      an unknown key falls back to that tier.
    - **`GET /stats` is an authenticated internal endpoint.** It requires a
      valid Bearer or `X-API-Key`, returns `401 auth-required` for an absent or
      unknown key, and deliberately omits `Access-Control-Allow-Origin` because
      its response contains lookup targets, client IPs, and per-key usage.
      Request a free key by emailing hello@disclose.io.
    - **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`.
    - **API errors use the JSON `ErrorEnvelope`.** The two non-error exceptions
      are `304 Not Modified` from `/api/lookup` (no body) and the structured
      `BountyHealth` body returned by `/healthz/bounty` when its data-quality
      probe reports `503`.
    - **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 confirmed contact
    records from heuristic or currently unconfirmed candidates. Records
    derived from authoritative sources (`security.txt`, SECURITY.md,
    DioDB, PSIRT directories) are verified; managed bug-bounty and VDP
    URLs are verified only after an HTTP reachability check succeeds.
    Heuristic contacts and program URLs whose reachability could not be
    confirmed carry `verified: false`. Coordinators should treat them 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

    Layered in-memory token buckets distinguish cheap transport from new
    lookup work. Headers from the IETF draft
    `draft-ietf-httpapi-ratelimit-headers` are emitted on responses:

    - `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 or overload 503).

    Transport budgets:

    | Endpoint                    | Anonymous | Standard key | Partner key | Window |
    | --------------------------- | --------- | ------------ | ----------- | ------ |
    | `POST /api/lookup`          | 600/IP    | 1,200        | 6,000       | 60s    |
    | `GET /api/lookup/progress`  | 240/IP    | 480          | 1,200       | 60s    |
    | `POST /api/evidence`        | 120/IP    | 30           | 60          | 60s    |
    | `POST /api/feedback`        | 10/IP     | 30           | 60          | 60s    |
    | `POST /mcp`                 | 600/IP    | 1,200        | 6,000       | 60s    |
    | `GET /stats`                | key required | 120       | 120         | 60s    |
    | `GET /healthz`              | unlimited | unlimited    | unlimited   | —      |
    | `GET /openapi.yaml`         | unlimited | unlimited    | unlimited   | —      |
    | `GET /api-docs`             | unlimited | unlimited    | unlimited   | —      |

    A lookup that creates new engine work also consumes a compute token:
    30/60s per valid `X-Lookup-Session`; 120/60s per IP when no session is
    supplied; 30/60s for unknown-IP traffic. API-key compute budgets are
    120/60s (`standard`) and 600/60s (`partner`), with transport ceilings of
    1,200 and 6,000. Cache hits and identical in-flight joins do not consume
    compute quota. The returned `RateLimit-*` fields describe the budget that
    constrained that response.

    Evidence collection has a separate compute budget because it performs
    bounded outbound inspection: 6/60s per valid `X-Lookup-Session`, 30/60s
    per identified IP without a session, 5/60s for unknown-IP traffic,
    30/60s for a standard key, and 60/60s for a partner key. Evidence cache
    hits and identical in-flight joins do not consume compute quota.

    ## Caching

    HTTP and MCP lookup responses share a process-local cache for 6 hours,
    keyed on normalized input plus any explicit asset type. Identical live
    misses are coalesced into one execution. Each HTTP response carries
    `X-Lookup-Cache: hit | miss | coalesced` and 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`, and richer
       per-contact feedback with `contactValue` + `outcome`. The lookup-bound
       `lookupId` + `rating` feedback contract is shipped.
    4. Per-contact `evidence` + `verificationMethod`.
    5. ~~Optional API key + per-key quota~~ — shipped for public endpoints.
       `Authorization: Bearer` or `X-API-Key` raises rate limits (`standard` /
       `partner` tiers); the anonymous per-IP tier is unchanged. `/stats` is
       the authenticated exception.
    6. `?deadline=ms` upper-bound parameter on `/api/lookup`.
  version: 2.4.0
  license:
    name: MIT
    identifier: MIT
  contact:
    name: disclose.io
    url: https://disclose.io
  x-logo:
    url: https://disclose.io/uploads/favicon.png
    altText: disclose.io
externalDocs:
  description: LLM-friendly docs index (llms.txt) — hand this to an AI coding agent
  url: https://lookup.disclose.io/llms.txt
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: evidence
    description: Experimental shadow-only technical attribution evidence
  - 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, reporting paths ordered by applicability to the queried
        owner/asset, the resolution chain, and the list of data sources queried.

        Inputs are auto-classified unless `kind` explicitly selects an asset
        type. Use prefix forms to disambiguate
        (`npm:foo`, `pypi:bar`, `gh:owner/repo`, `app:WhatsApp`,
        `hw:Cisco ASA`, `ext:uBlock Origin`, `desktop:Slack`). Browser
        extensions also accept `ext:chrome:<id>`, `ext:firefox:<slug>`, and
        Chrome/Firefox/Edge store URLs. 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
      security:
        - {}
        - BearerApiKey: []
        - ApiKeyHeader: []
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -s https://lookup.disclose.io/api/lookup \
              -H 'Content-Type: application/json' \
              -d '{"input":"cloudflare.com"}'
        - lang: JavaScript
          label: fetch
          source: |
            const stableSessionId = crypto.randomUUID(); // create once, persist, and reuse
            const res = await fetch("https://lookup.disclose.io/api/lookup", {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                "X-Lookup-Session": stableSessionId,
              },
              body: JSON.stringify({ input: "cloudflare.com" }),
            });
            const result = await res.json();
            console.log(result.attribution.organization, result.contacts);
        - lang: Python
          label: requests
          source: |
            import requests
            r = requests.post(
                "https://lookup.disclose.io/api/lookup",
                json={"input": "cloudflare.com"},
                timeout=30,
            )
            print(r.json()["attribution"]["organization"])
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
        - $ref: '#/components/parameters/LookupSession'
        - $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'
            X-Lookup-Cache:
              $ref: '#/components/headers/XLookupCache'
            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'
              examples:
                complete:
                  summary: A domain with strong reporting channels
                  value:
                    input: cloudflare.com
                    assetType: domain
                    timestamp: '2026-06-20T12:34:56.789Z'
                    status: complete
                    requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
                    hasErrors: false
                    attribution:
                      organization: Cloudflare
                      confidence: medium
                    contacts:
                      - type: security_txt
                        value: https://www.cloudflare.com/abuse/
                        label: Security Contact (security.txt)
                        source: security-txt
                        confidence: high
                        verified: true
                      - type: vdp
                        value: https://hackerone.com/cloudflare
                        label: Disclosure Program (via security.txt)
                        source: security-txt
                        confidence: high
                        verified: true
                    chains: []
                    dataSources:
                      - name: security-txt-host
                        queried: true
                        confidence: 5
                      - name: bounty-platforms
                        queried: true
                        confidence: 4
                    details: {}
                partial:
                  summary: Attribution succeeded but only fallback contacts surfaced
                  value:
                    input: example-vendor.com
                    assetType: domain
                    timestamp: '2026-06-20T12:34:56.789Z'
                    status: partial
                    requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e90
                    hasErrors: false
                    attribution:
                      organization: Example Vendor
                      jurisdiction: US
                      confidence: medium
                    contacts:
                      - type: convention
                        value: security@example-vendor.com
                        label: Convention security address (unverified)
                        source: convention-email
                        confidence: low
                        verified: false
                    chains: []
                    dataSources:
                      - name: security-txt-apex
                        queried: true
                        confidence: 0
                    details: {}
        '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'
            X-Lookup-Cache:
              $ref: '#/components/headers/XLookupCache'
            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'
        '503':
          $ref: '#/components/responses/LookupOverloaded'
  /api/lookup/progress:
    get:
      tags: [lookup]
      summary: Read progress for an active browser lookup
      description: |
        Returns coarse progress emitted by work the lookup engine actually
        started or completed. Progress is short-lived and bound to the same
        opaque `X-Lookup-Session` supplied to `POST /api/lookup`; a missing or
        mismatched session returns 404. Snapshots never contain the lookup
        input or result data.
      operationId: lookupProgress
      security:
        - {}
        - BearerApiKey: []
        - ApiKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
        - $ref: '#/components/parameters/LookupSession'
        - name: requestId
          in: query
          required: true
          description: The `X-Request-Id` supplied to the active lookup request.
          schema:
            type: string
      responses:
        '200':
          description: Current progress 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/LookupProgressSnapshot'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /api/evidence:
    post:
      tags: [evidence]
      summary: Collect experimental technical attribution evidence
      description: |
        Collects a bounded `attribution-evidence.v1` graph for one public
        HTTP(S) site, domain, bare public IPv4/IPv6 address, or an explicitly
        asserted organization name with `intent: organization-domain`. The
        graph contains passive technical clues,
        typed relationships, provenance, module health, and abstaining or
        uncalibrated decisions.

        This operation is **experimental**. It is isolated from the disclosure
        lookup pipeline: every successful graph carries `mode: "shadow"` and
        `routingImpact: "none"`; it cannot change owner attribution, reporting
        contacts, contact order, or the response from `POST /api/lookup`.
        Censys, when configured, contributes an exact root-property endpoint
        `served_by` relationship. Redirected final-property endpoints remain
        context on that final property. Certificates, redirects, DNS names, network
        routing/registration, shared-infrastructure labels, and software remain
        unscored observations on their actual site/host subjects. None of
        these records is ownership, a candidate owner, or a reporting route.

        `intent: product-vendor` opts a site/IP request into exact,
        high-confidence CPE product/vendor observations. It never reinterprets
        the default asset-owner request, and shared/common/ambiguous detections
        abstain. `intent: organization-domain` may use one bounded Censys global
        search page. Exact normalized full legal-identity certificate matches
        remain candidate observations unless current, rare, and separately
        corroborated by an explicit first-party source; then they are labeled
        `corroborated candidate`, not ownership/contact verified. A
        certificate does not prove domain ownership. Any signal that more
        search results exist disables that stronger label; no second page is
        fetched.

        Production currently enables anonymous CORS access. Self-hosted
        deployments can disable the operation (404) or require a valid API key
        (401). Successful responses are never cacheable by clients or indexed.
        Request objects are closed; unknown fields are rejected with 400.
      operationId: collectAttributionEvidence
      x-experimental: true
      security:
        - {}
        - BearerApiKey: []
        - ApiKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
        - $ref: '#/components/parameters/LookupSession'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EvidenceRequest'
            examples:
              domain:
                value: { input: lookup.disclose.io }
              site:
                value: { input: 'https://lookup.disclose.io/how-it-works' }
              organizationDomain:
                value: { input: 'Disclose.io Foundation', intent: organization-domain }
              productVendor:
                value: { input: 'https://lookup.disclose.io', intent: product-vendor }
      responses:
        '200':
          description: Experimental shadow evidence graph collected or served from the short-lived process cache.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            X-Evidence-Cache:
              $ref: '#/components/headers/XEvidenceCache'
            Cache-Control:
              $ref: '#/components/headers/CacheControlNoStore'
            X-Robots-Tag:
              $ref: '#/components/headers/XRobotsNoIndex'
            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/EvidenceGraph'
              example:
                schemaVersion: attribution-evidence.v1
                mode: shadow
                routingImpact: none
                input: lookup.disclose.io
                subject:
                  id: 'site:https://lookup.disclose.io'
                  type: site
                  displayName: lookup.disclose.io
                collectedAt: '2026-08-10T12:00:00.000Z'
                budgets:
                  timeoutMs: 5000
                  maxResponseBytes: 524288
                  maxExternalResponseBytes: 1048576
                  maxModules: 4
                  maxClaims: 40
                  maxQuotedValueChars: 240
                artifacts: []
                observations: []
                claims: []
                decisions: []
                modules: []
                errors: []
        '400':
          $ref: '#/components/responses/InvalidEvidenceRequest'
        '401':
          $ref: '#/components/responses/EvidenceAuthRequired'
        '404':
          $ref: '#/components/responses/EvidenceUnavailable'
        '413':
          $ref: '#/components/responses/EvidenceBodyTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/EvidenceOverloaded'
  /api/feedback:
    post:
      tags: [feedback]
      summary: Submit user feedback on a recent lookup
      description: |
        Record one rating for a real, recent lookup. `lookupId` must equal the
        `requestId` returned by a previous `POST /api/lookup` or MCP lookup;
        the server retrieves the original input from its own request log and
        ignores client-asserted lookup data. Ratings normalize to `up`, `down`,
        or `unclear`. Re-submitting feedback for the same lookup is an
        idempotent success with `deduped: true`. Structured reasons, an
        expected asset type, and a proposed owner can be supplied for review.
        Free-text and proposed-owner values remain untrusted, sanitized, and
        length-capped; they never alter a live lookup automatically.
      operationId: submitFeedback
      security:
        - {}
        - BearerApiKey: []
        - ApiKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FeedbackRequest'
            example:
              lookupId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
              rating: down
              reasons: [wrong_owner, wrong_reporting_route]
              suggestedOwner: Example Holdings
              comment: The listed program belongs to a different organization.
      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'
        '400':
          $ref: '#/components/responses/InvalidFeedback'
        '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
      security:
        - {}
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
      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
      security:
        - {}
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
      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
        '500':
          $ref: '#/components/responses/InternalError'
  /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.
        This is an internal endpoint because the response includes lookup
        targets, client IPs, and per-key usage. It requires a valid API key,
        is not CORS-open, is not linked from the UI, and emits
        `X-Robots-Tag: noindex, nofollow, noarchive` plus `Cache-Control:
        no-store`.
      operationId: stats
      security:
        - BearerApiKey: []
        - ApiKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
        - name: since
          in: query
          required: false
          description: |
            Restrict the windowed aggregates (`totals.windowed`, plus the
            `byPath` / `lookups` / `apiKeys` rollups) to entries newer than this
            rolling window. Format `<n><unit>` where unit is `m` (minutes),
            `h` (hours), `d` (days), or `w` (weeks) — e.g. `30m`, `72h`, `3d`,
            `1w`. Omit for all-time stats. Unparseable values are ignored and
            fall back to all-time.
          schema:
            type: string
            pattern: '^\d+\s*[mhdw]$'
            examples: ['72h', '3d', '30m', '1w']
      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'
            X-Robots-Tag:
              description: Prevents indexing and archival of the internal metrics response.
              schema:
                type: string
                const: noindex, nofollow, noarchive
            Cache-Control:
              description: Internal metrics responses are never cacheable.
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatsSnapshot'
        '401':
          $ref: '#/components/responses/AuthRequired'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /mcp:
    post:
      tags: [mcp]
      summary: Model Context Protocol streamable-HTTP transport
      description: |
        Exposes the lookup tools as an MCP server over streamable HTTP per
        the Model Context Protocol spec. Stateless mode. Authentication is
        optional and uses the same Bearer/X-API-Key tiers as REST. See
        https://modelcontextprotocol.io for the wire protocol.

        Tools:
        - `lookup_security_contact` — full lookup; returns a Markdown summary
          (`content[0].text`) plus the structured `LookupResult`
          (`structuredContent`), identical to `POST /api/lookup`.
        - `classify_asset` — instant asset-type classifier, no network.

        Clients **must** send `Accept: application/json, text/event-stream`.
        Add to Claude Code in one line:
        `claude mcp add --transport http lookup https://lookup.disclose.io/mcp`
        or point any URL-based MCP client at `https://lookup.disclose.io/mcp`.
      operationId: mcp
      security:
        - {}
        - BearerApiKey: []
        - ApiKeyHeader: []
      x-codeSamples:
        - lang: Shell
          label: Add to Claude Code
          source: |
            claude mcp add --transport http lookup https://lookup.disclose.io/mcp
        - lang: Shell
          label: tools/call via curl
          source: |
            curl -s -X POST https://lookup.disclose.io/mcp \
              -H 'Content-Type: application/json' \
              -H 'Accept: application/json, text/event-stream' \
              -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup_security_contact","arguments":{"asset":"cloudflare.com"}}}'
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
        - name: Accept
          in: header
          required: true
          description: Must be `application/json, text/event-stream` for the streamable-HTTP transport.
          schema:
            type: string
            default: application/json, text/event-stream
        - $ref: '#/components/parameters/LookupSession'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: JSON-RPC 2.0 envelope per the MCP spec.
              additionalProperties: true
            examples:
              initialize:
                summary: Handshake
                value:
                  jsonrpc: '2.0'
                  id: 1
                  method: initialize
                  params:
                    protocolVersion: '2025-03-26'
                    clientInfo: { name: my-client, version: '1.0' }
                    capabilities: {}
              toolsList:
                summary: List available tools
                value:
                  jsonrpc: '2.0'
                  id: 2
                  method: tools/list
              toolsCall:
                summary: Call lookup_security_contact
                value:
                  jsonrpc: '2.0'
                  id: 3
                  method: tools/call
                  params:
                    name: lookup_security_contact
                    arguments: { asset: cloudflare.com }
      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
      security:
        - {}
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
      responses:
        '200':
          description: The spec itself.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            text/yaml:
              schema:
                type: string
  /api-docs:
    get:
      tags: [docs]
      summary: Interactive Swagger UI for this API
      operationId: apiDocs
      security:
        - {}
      parameters:
        - $ref: '#/components/parameters/InboundRequestId'
      responses:
        '200':
          description: Swagger UI HTML.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            text/html:
              schema:
                type: string
components:
  securitySchemes:
    BearerApiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <key>` selects the documented keyed quota and
        tags requests for per-key analytics; it is required as one of two
        alternatives on `/stats`. Anonymous public endpoints work without it,
        and an unknown key on those endpoints falls back to the anonymous
        per-IP limit; `/stats` rejects an absent or unknown key.
        Core lookup quotas increase; experimental evidence keeps separate,
        lower keyed budgets because each new collection performs outbound work.
        Because the current CORS preflight allow-list omits `Authorization`,
        this scheme is usable from server-side and same-origin clients, not
        cross-origin browser callers.
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        `X-API-Key` is an alternative to the Bearer scheme with the same keyed
        quota and `/stats` semantics. The current CORS preflight allow-list
        omits this header, so it is usable from server-side and same-origin
        clients, not cross-origin browser callers.
  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._:\-]+$'
    LookupSession:
      name: X-Lookup-Session
      in: header
      required: false
      description: |
        Stable opaque browser/client identifier used only to give individual
        users a fair new-work allowance behind shared NATs. Must be 20–128
        characters from `A-Z a-z 0-9 . _ : -`. Create once and persist; do not
        rotate per request. Missing or malformed values fall back to IP policy.
        API-key requests use their key quota instead.
      schema:
        type: string
        minLength: 20
        maxLength: 128
        pattern: '^[A-Za-z0-9._:\-]{20,128}$'
    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
    XLookupCache:
      description: |
        Lookup execution state: `hit` came from the completed response cache,
        `coalesced` joined identical work already in progress, and `miss`
        executed the lookup engine.
      schema:
        type: string
        enum: [hit, miss, coalesced]
    XEvidenceCache:
      description: |
        Evidence execution state: `hit` came from the five-minute process
        cache, `coalesced` joined identical collection already in progress,
        and `miss` executed the bounded evidence collector.
      schema:
        type: string
        enum: [hit, miss, coalesced]
    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'
    CacheControlNoStore:
      description: The experimental evidence response must not be stored by clients or intermediaries.
      schema:
        type: string
        const: no-store
    XRobotsNoIndex:
      description: Prevents indexing, following, and archival of the experimental evidence response.
      schema:
        type: string
        const: noindex, nofollow, noarchive
    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
    InvalidEvidenceRequest:
      description: |
        The body is invalid JSON, `input` is missing/non-string/empty, or the
        input is not valid for its requested evidence intent.
      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/ErrorEnvelope'
          examples:
            missingInput:
              value:
                status: error
                errorMessage: Missing input field
                errorCode: missing-input
                requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
            invalidSite:
              value:
                status: error
                errorMessage: Input must be a public HTTP(S) site, domain, or bare IP
                errorCode: invalid-input
                requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    EvidenceAuthRequired:
      description: The enabled evidence route requires a valid API key in this deployment.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            errorMessage: API key required for /api/evidence
            errorCode: auth-required
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    EvidenceUnavailable:
      description: The experimental evidence feature is disabled in this deployment.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            errorMessage: Not found
            errorCode: not-found
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    EvidenceBodyTooLarge:
      description: The evidence request body exceeds the 4 KiB boundary limit.
      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/ErrorEnvelope'
          example:
            status: error
            errorMessage: Request body exceeds 4096 bytes
            errorCode: body-too-large
            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: |
        Transport or new-work 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'
          examples:
            transport:
              value:
                status: rate_limited
                errorMessage: Rate limit exceeded for /api/lookup — 600 requests per 60 seconds per IP
                errorCode: rate-limited
                requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
            lookupCompute:
              value:
                status: rate_limited
                errorMessage: Lookup compute limit exceeded — 30 new lookups per 60 seconds per session; retry after 2 seconds
                errorCode: lookup-compute-rate-limited
                requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
            evidenceCompute:
              value:
                status: rate_limited
                errorMessage: Evidence compute limit exceeded — 6 new collections per 60 seconds per session; retry after 2 seconds
                errorCode: evidence-compute-rate-limited
                requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    LookupOverloaded:
      description: |
        The bounded lookup execution queue is full or its short wait expired.
        Cached and coalesced requests remain serviceable. Retry with jitter
        after the indicated delay.
      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 before retrying the lookup.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            errorMessage: Lookup service is busy; retry after 2 seconds
            errorCode: lookup-overloaded
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    EvidenceOverloaded:
      description: |
        The bounded evidence collection queue is full or its short wait
        expired. Cached and coalesced graphs remain serviceable. Retry with
        jitter after the indicated delay.
      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 before retrying evidence collection.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            errorMessage: Evidence service is busy; retry shortly
            errorCode: evidence-overloaded
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    AuthRequired:
      description: A valid Bearer or `X-API-Key` credential is required.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            errorMessage: API key required for /stats
            errorCode: auth-required
            requestId: req_018f4f1a-7c2e-7a31-9a7b-3a4f5d6c7e89
    InvalidFeedback:
      description: Rating is invalid, or the lookup identifier does not reference a logged lookup.
      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/ErrorEnvelope'
          example:
            status: error
            errorMessage: feedback requires a valid lookupId from a recent lookup
            errorCode: invalid-feedback
            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:
    EvidenceRequest:
      type: object
      additionalProperties: false
      required: [input]
      properties:
        input:
          type: string
          minLength: 1
          description: |
            One public domain, HTTP(S) site, or bare public IPv4/IPv6 address
            to inspect. With `intent: organization-domain`, one explicitly
            asserted organization name is accepted instead. Embedded
            credentials, non-HTTP(S) schemes,
            documentation/reserved/special-use names or IP literals,
            local/private targets, control characters, and invalid URLs are
            rejected before collection. Under `organization-domain`, reserved
            single-label names such as `localhost`, `localdomain`, and `onion`
            are also rejected before execution.
        intent:
          $ref: '#/components/schemas/EvidenceIntent'
    EvidenceIntent:
      type: string
      default: asset-owner
      enum: [asset-owner, organization-domain, product-vendor]
      description: |
        Request-only focus for the experimental evidence collector. Omit or
        use `asset-owner` for the existing site/IP technical-clue path.
        `organization-domain` accepts an organization name and returns only
        unscored domain-candidate observations. `product-vendor` accepts a
        site/IP and enables exact high-confidence CPE observations. This value
        is not serialized into attribution-evidence.v1 and never changes
        `/api/lookup`, attribution, contacts, completion, or route order.
    EvidenceEntityType:
      type: string
      enum: [site, domain, artifact, repository, project, account, host, organization, person, provider]
    EvidenceEntity:
      type: object
      additionalProperties: false
      required: [id, type, displayName]
      properties:
        id:
          type: string
          description: Stable namespaced identity for this graph, such as `repository:github.com/acme/docs`.
        type:
          $ref: '#/components/schemas/EvidenceEntityType'
        displayName:
          type: string
        locator:
          type: object
          additionalProperties:
            type: string
    EvidenceLiteral:
      type: object
      additionalProperties: false
      required: [type, value]
      properties:
        type:
          type: string
          enum: [string, url, timestamp]
        value:
          type: string
    EvidenceValue:
      oneOf:
        - $ref: '#/components/schemas/EvidenceEntity'
        - $ref: '#/components/schemas/EvidenceLiteral'
    EvidencePredicate:
      type: string
      enum: [references_repository, built_from, deployed_via, served_by, maintained_by, operated_by, historically_used]
    EvidenceModuleVersion:
      type: object
      additionalProperties: false
      required: [id, version]
      properties:
        id:
          type: string
        version:
          type: string
    EvidenceTimeInterval:
      type: object
      additionalProperties: false
      required: [certainty]
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        certainty:
          type: string
          enum: [observed, inferred, unknown]
    EvidenceSourceArtifact:
      type: object
      additionalProperties: false
      required: [id, type, requestedUrl, finalUrl, retrievedAt, status, byteCount, digest, redirectCount, headers, contentStored]
      description: Privacy-safe metadata for a fetched public response. Response bodies, cookies, and authorization data are never serialized.
      properties:
        id:
          type: string
        type:
          type: string
          const: http-response
        requestedUrl:
          type: string
          format: uri
        finalUrl:
          type: string
          format: uri
        retrievedAt:
          type: string
          format: date-time
        status:
          type: integer
          minimum: 100
          maximum: 599
        mediaType:
          type: string
        byteCount:
          type: integer
          minimum: 0
        digest:
          type: string
          description: Content digest; the body itself is discarded.
        redirectCount:
          type: integer
          minimum: 0
        headers:
          type: object
          description: Allowlisted public diagnostic headers only.
          additionalProperties:
            type: string
        excerpt:
          type: string
          description: Optional bounded public page title, never an arbitrary body excerpt.
        contentStored:
          type: boolean
          const: false
    EvidenceSourceRef:
      type: object
      additionalProperties: false
      required: [artifactId, url]
      properties:
        artifactId:
          type: string
        url:
          type: string
          format: uri
        selector:
          type: string
        quotedValue:
          type: string
        transform:
          type: string
    EvidenceObservation:
      type: object
      additionalProperties: false
      required: [id, module, observedAt, subject, sourceRefs, kind, value, causalGroup]
      properties:
        id:
          type: string
        module:
          $ref: '#/components/schemas/EvidenceModuleVersion'
        observedAt:
          type: string
          format: date-time
        subject:
          $ref: '#/components/schemas/EvidenceEntity'
        sourceRefs:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceSourceRef'
        kind:
          type: string
          description: Module-defined observation kind; contextual observations are not owner verdicts.
        value:
          $ref: '#/components/schemas/EvidenceValue'
        causalGroup:
          type: string
    EvidenceFeatures:
      type: object
      additionalProperties: false
      required: [directness, specificity, freshness, extractorCertainty]
      properties:
        directness:
          type: string
          enum: [direct, derived, context]
        specificity:
          type: string
          enum: [exact, scoped, generic]
        freshness:
          type: string
          enum: [current, historical, unknown]
        extractorCertainty:
          type: string
          enum: [deterministic, heuristic]
    EvidenceClaimObservability:
      type: object
      additionalProperties: false
      required: [established, reason]
      properties:
        established:
          type: boolean
        reason:
          type: string
        artifactId:
          type: string
    EvidenceClaim:
      type: object
      additionalProperties: false
      required: [id, module, observedAt, subject, predicate, object, polarity, evidenceFamily, causalGroup, sourceRefs, features, observability]
      properties:
        id:
          type: string
        module:
          $ref: '#/components/schemas/EvidenceModuleVersion'
        observedAt:
          type: string
          format: date-time
        validTime:
          $ref: '#/components/schemas/EvidenceTimeInterval'
        subject:
          $ref: '#/components/schemas/EvidenceEntity'
        predicate:
          $ref: '#/components/schemas/EvidencePredicate'
        object:
          $ref: '#/components/schemas/EvidenceValue'
        polarity:
          type: string
          enum: [supports, contradicts, context]
        evidenceFamily:
          type: string
        causalGroup:
          type: string
        sourceRefs:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceSourceRef'
        features:
          $ref: '#/components/schemas/EvidenceFeatures'
        observability:
          $ref: '#/components/schemas/EvidenceClaimObservability'
    EvidenceModuleExecution:
      type: object
      additionalProperties: false
      required: [module, status, durationMs, artifactCount, observationCount, claimCount]
      properties:
        module:
          $ref: '#/components/schemas/EvidenceModuleVersion'
        status:
          type: string
          enum: [completed, skipped, failed]
        durationMs:
          type: number
          minimum: 0
        artifactCount:
          type: integer
          minimum: 0
        observationCount:
          type: integer
          minimum: 0
        claimCount:
          type: integer
          minimum: 0
        notes:
          type: array
          items:
            type: string
        error:
          type: string
    EvidenceCandidateAssessment:
      type: object
      additionalProperties: false
      required: [object, evidenceScore, supportingScore, contradictingScore, supportingClaimIds, contradictingClaimIds, causalGroups]
      properties:
        object:
          $ref: '#/components/schemas/EvidenceValue'
        evidenceScore:
          type: number
          description: Uncalibrated ranking evidence, not a probability.
        supportingScore:
          type: number
        contradictingScore:
          type: number
        supportingClaimIds:
          type: array
          items:
            type: string
        contradictingClaimIds:
          type: array
          items:
            type: string
        causalGroups:
          type: array
          items:
            type: string
    EvidenceCalibration:
      type: object
      additionalProperties: false
      required: [status, version, evidenceScore]
      description: Explicitly uncalibrated ranking output; `evidenceScore` is not a probability or confidence percentage.
      properties:
        status:
          type: string
          const: uncalibrated
        version:
          type: string
          const: shadow-v0
        evidenceScore:
          type: number
    EvidenceAttributionDecision:
      type: object
      additionalProperties: false
      required: [id, subject, predicate, status, asOf, calibration, candidates, contributingClaimIds, discountedClaimIds, ignoredClaimIds, explanation]
      properties:
        id:
          type: string
        subject:
          $ref: '#/components/schemas/EvidenceEntity'
        predicate:
          $ref: '#/components/schemas/EvidencePredicate'
        status:
          type: string
          enum: [supported, ambiguous, contradicted, abstained]
        selectedObject:
          $ref: '#/components/schemas/EvidenceValue'
        asOf:
          type: string
          format: date-time
        calibration:
          $ref: '#/components/schemas/EvidenceCalibration'
        candidates:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceCandidateAssessment'
        contributingClaimIds:
          type: array
          items:
            type: string
        discountedClaimIds:
          type: array
          items:
            type: string
        ignoredClaimIds:
          type: array
          items:
            type: string
        explanation:
          type: string
    EvidenceBudgets:
      type: object
      additionalProperties: false
      required: [timeoutMs, maxResponseBytes, maxModules, maxClaims, maxQuotedValueChars]
      properties:
        timeoutMs:
          type: integer
          minimum: 1
        maxResponseBytes:
          type: integer
          minimum: 1
        maxExternalResponseBytes:
          type: integer
          minimum: 1
          description: Optional separate ceiling for metadata APIs such as Censys.
        maxModules:
          type: integer
          minimum: 1
        maxClaims:
          type: integer
          minimum: 1
        maxQuotedValueChars:
          type: integer
          minimum: 1
    EvidenceError:
      type: object
      additionalProperties: false
      required: [stage, message]
      properties:
        stage:
          type: string
          enum: [input, fetch, module, decision]
        moduleId:
          type: string
        message:
          type: string
    EvidenceGraph:
      type: object
      additionalProperties: false
      description: |
        Experimental technical-evidence graph. It is deliberately separate
        from `LookupResult`; `mode` and `routingImpact` are fixed safeguards.
      required: [schemaVersion, mode, routingImpact, input, collectedAt, budgets, artifacts, observations, claims, decisions, modules, errors]
      properties:
        schemaVersion:
          type: string
          const: attribution-evidence.v1
        mode:
          type: string
          const: shadow
        routingImpact:
          type: string
          const: none
        input:
          type: string
        subject:
          $ref: '#/components/schemas/EvidenceEntity'
        collectedAt:
          type: string
          format: date-time
        budgets:
          $ref: '#/components/schemas/EvidenceBudgets'
        artifacts:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceSourceArtifact'
        observations:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceObservation'
        claims:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceClaim'
        decisions:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceAttributionDecision'
        modules:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceModuleExecution'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/EvidenceError'
    ResponseStatus:
      type: string
      description: |
        Stable status discriminator emitted by `LookupResult` and
        `ErrorEnvelope`. Health, stats, MCP transport, and documentation
        responses use their own response shapes.
      enum:
        - complete
        - partial
        - failed
        - rate_limited
        - not_found
        - error
      x-enum-descriptions:
        complete: 200 — lookup found at least one strong owner-qualified reporting route.
        partial: 200 — useful fallback/operator/coordination routing exists, but no strong owner-qualified route surfaced.
        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
        - cna
        - cert
        - convention
    ContactRouteClass:
      type: string
      description: |
        How this contact can route a report for the queried asset. This is
        provenance- and asset-aware; it is not a synonym for contact type.
      enum: [first_party, authorized_agent, responsible_operator, related_party, inferred, coordinator]
    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. `verified_guess` is a
        heuristic org→domain match (homepage `schema.org`/`og:site_name`)
        that is deliberately NOT high-trust — it does not relax the guard.
        `publisher`, `build_origin`, `identifier_assignee`, and
        `disclosure_agent` are source-backed non-owner edges: they can add
        routing/provenance groups but cannot establish ownership or complete
        a result alone. `historical_attribution`, `historical_domain`, and
        `historical_repository` are archived Common Crawl breadcrumbs. They
        receive an evidence penalty and must be checked through live strategies.
      enum:
        - parent_company
        - parent_company_domain
        - subsidiary_domain
        - brand_domain
        - weak_inference
        - canonical_alias
        - related
        - manufacturer
        - developer
        - platform_verified
        - verified_guess
        - platform_host
        - publisher
        - build_origin
        - identifier_assignee
        - disclosure_agent
        - infra_operator
        - historical_attribution
        - historical_domain
        - historical_repository
    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 confirmed under that source's verification
            rule (for example owner-published security.txt or SECURITY.md).
            Managed bug-bounty and VDP URLs are true only after an HTTP
            reachability check succeeds. CNA roster presence establishes
            provenance, not endpoint reachability, so CNA contacts remain false.
            **`false`** for heuristic guesses (convention emails like
            `security@`/`abuse@`) and program URLs whose reachability could
            not be confirmed.
            When otherwise-equivalent convention emails both exist, `security@`
            is ordered before `abuse@`. This is only a within-convention
            tiebreak; it never promotes a guess above an observed or
            owner-authorized route.
            Coordinators should treat `verified: false` contacts as
            candidates requiring confirmation before disclosure outreach.
        entity:
          type: string
          description: Display name of the party this channel reaches (additive, optional).
        entityKey:
          type: string
          description: Stable normalized key used to cluster channels by party (additive, optional).
        relation:
          $ref: '#/components/schemas/ContactEntityRelation'
        routeClass:
          $ref: '#/components/schemas/ContactRouteClass'
        deliveryAgent:
          type: string
          description: |
            Managed disclosure service carrying a scope-matched report for the
            owner (for example HackerOne or Bugcrowd). The contact remains
            grouped under the target owner rather than the platform itself.
        authoritative:
          type: boolean
          description: |
            **`true`** only for a current owner-published RFC 9116
            `security.txt` (`security-txt-apex` / `security-txt-host`) or
            dnssecuritytxt DNS TXT record (`dns-security-txt`). This normally
            means a declaration on the queried registrable domain. A chained
            declaration can qualify only through an explicit owner/subsidiary
            relationship when the declaration's own reporting destination
            returns to the queried root domain.
            Authoritative contacts are PINNED ahead of all non-authoritative
            contacts in both `contacts[]` and each `ContactGroup`. The flag is
            omitted for parent/host/unrelated-child channels, uncorroborated
            chains, and a `security.txt` whose `Expires` date has passed.
            Additive and optional; absence means "not owner-authoritative".
    ContactEntityRelation:
      type: string
      description: Relation of a contact's entity to the looked-up artifact.
      enum: [self, vendor, host, parent, subsidiary, maintainer, publisher, build_origin, identifier_assignee, network_operator, disclosure_agent, historical, coordinator]
    ContactGroup:
      type: object
      description: |
        A cluster of channels that all reach the same party. Groups are ordered
        owner/vendor first, then responsible stewards/operators, related parties,
        inferred leads, and coordination backstops. See README "Contact grouping & ordering".
      required: [entity, entityKey, contacts]
      properties:
        entity:
          type: string
        entityKey:
          type: string
        relation:
          $ref: '#/components/schemas/ContactEntityRelation'
        routeClass:
          $ref: '#/components/schemas/ContactRouteClass'
        scopeNote:
          type: string
          description: Present for host and non-owner relationship groups; clarifies why the entity is useful without presenting it as the owner.
        rationale:
          type: string
          description: One-line reason this group ranks where it does.
        contacts:
          type: array
          items:
            $ref: '#/components/schemas/ContactChannel'
    RouteSummary:
      type: object
      required: [routeClass, headline, firstPartyFound, ownerRouteFound, coordinatorAvailable]
      properties:
        routeClass:
          oneOf:
            - $ref: '#/components/schemas/ContactRouteClass'
            - type: string
              enum: [none]
        headline:
          type: string
          description: Human-readable outcome that distinguishes first-party, operator, and coordination routes.
        firstPartyFound:
          type: boolean
        ownerRouteFound:
          type: boolean
          description: True for a first-party or owner-authorized managed reporting route.
        coordinatorAvailable:
          type: boolean
    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. Browser extensions
            also accept `ext:chrome:<id>`, `ext:firefox:<slug>`, and store URLs.
          minLength: 1
        kind:
          $ref: '#/components/schemas/AssetType'
          description: |
            Optional explicit asset type. When present, the REST endpoint runs
            that strategy instead of auto-detecting from `input`. Useful for
            ambiguous bare names such as apps, hardware, and organizations.
    LookupProgressEvent:
      type: object
      required: [stage, label]
      properties:
        stage:
          type: string
          enum: [queued, classifying, checking_sources, following_relationships, ranking_routes, complete]
        label:
          type: string
        detail:
          type: string
        assetType:
          $ref: '#/components/schemas/AssetType'
        completed:
          type: integer
          minimum: 0
        total:
          type: integer
          minimum: 0
        depth:
          type: integer
          minimum: 0
    LookupProgressSnapshot:
      type: object
      required: [requestId, status, event, updatedAt]
      properties:
        requestId:
          type: string
        status:
          type: string
          enum: [active, complete, error]
        event:
          $ref: '#/components/schemas/LookupProgressEvent'
        updatedAt:
          type: string
          format: date-time
    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'
        contactGroups:
          type: array
          description: |
            Additive. The same channels as `contacts`, clustered by the party
            each reaches and ordered by the documented routing standard. The flat
            `contacts` array is preserved unchanged for backward compatibility.
          items:
            $ref: '#/components/schemas/ContactGroup'
        routeSummary:
          $ref: '#/components/schemas/RouteSummary'
        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.
            Result-quality signal (additive, present on normal 200 results):
              ownerContactFound: boolean — false when the only channels are universal
                CERT/CC coordinators, i.e. no real owner/vendor contact was found. A
                `partial` carrying `ownerContactFound: false` should not be read as a
                meaningful owner result.
              kind: "coordinator_only" — set when CERT/CC is the sole channel.
            For `container` inputs, `details.container` carries `{ registry, registryName,
            repository, tag, digest, inferredOrg, officialImage, attributable }`;
            `attributable: false` marks a private, account-scoped registry (ACR / per-account
            ECR) that cannot be attributed to a public owner.
            When a local Common Crawl evidence index is configured, `details.commonCrawl`
            contains timestamped archived observations and capture provenance. Those
            observations are historical leads only: archived contacts never appear in
            `contacts`, and only explicit organization/domain/repository edges may fan
            out through the normal live strategies (maximum three, root lookup only).
        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
      required: [rating]
      anyOf:
        - required: [lookupId]
        - required: [requestId]
      properties:
        lookupId:
          type: string
          description: Preferred identifier of the prior lookup being rated (`LookupResult.requestId`).
        requestId:
          type: string
          deprecated: true
          description: Legacy alias for `lookupId`.
        rating:
          type: string
          enum: [up, down, unclear, positive, good, yes, helpful, '👍', negative, bad, no, wrong, '👎', unsure, partial, maybe]
          description: Canonical values are `up`, `down`, and `unclear`; the remaining values are accepted aliases.
        comment:
          type: string
          maxLength: 1000
          description: Optional untrusted text. Control characters are replaced, whitespace is collapsed, and the stored value is capped at 1,000 characters.
        reasons:
          type: array
          maxItems: 4
          uniqueItems: true
          items:
            type: string
            enum: [wrong_owner, wrong_reporting_route, missing_reporting_route, wrong_asset_type, unclear_explanation, stale_information, other]
          description: Optional structured review reasons; unknown and duplicate values are discarded.
        expectedAssetType:
          $ref: '#/components/schemas/AssetType'
          description: Optional corrected interpretation when `wrong_asset_type` is selected.
        suggestedOwner:
          type: string
          maxLength: 200
          description: Optional untrusted owner-attribution suggestion for human review. Never changes a result automatically.
    FeedbackResponse:
      type: object
      required: [ok, requestId]
      properties:
        ok:
          type: boolean
          const: true
        requestId:
          type: string
        deduped:
          type: boolean
          description: Present and true when feedback for this lookup was already recorded.
    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.
