openapi: 3.1.0
info:
  title: AgentFit
  # NOTE: this value is OVERWRITTEN at serve time with internal/version.Tag
  # (cmd/server openAPIHandler) so the served contract always tracks the running
  # build — internal/version.Tag stays the single source of truth (no second
  # file to bump on release). The literal here is only a fallback for readers of
  # the file on disk.
  version: v2.34.0
  description: |
    Audit public API documentation for AI/agent-readiness against the 30-criteria rubric
    defined at https://www.gumeniuk.com/en/tech/agentfit-metodologiya/.

    Output JSON is byte-compatible with the LLM-audit prompt from the original article (linked there),
    so a human can compare an LLM-driven audit and this service's code-driven audit
    side by side.

    ## Authentication
    This public API is **unauthenticated**. A separate authenticated admin API
    (JWT bearer) backs the first-party `/admin` console; it is intentionally not
    documented here and is not for third-party use.

    ## Recommended flow
    Audits can take up to ~30s. `POST /audit` is **synchronous by default** (blocks
    and returns the report as `200`, up to the server's 120s ceiling) on every
    deployment. For interactive/UI clients, opt into **async** with
    `?async=true`: it returns `202` with a `poll_url`; poll
    `GET /api/public/audit/{id}` every 2–3s until `status` is `done` **or**
    `error`. (Synchronous callers should set a client timeout of at least 120s.)

    ```bash
    # async (recommended for UI / non-blocking clients)
    RUN=$(curl -s -X POST 'https://agentfit.dev/audit?async=true' \
      -H 'Content-Type: application/json' \
      -d '{"base_url":"https://docs.stripe.com"}' | jq -r .run_id)
    # poll until done|error
    curl -s "https://agentfit.dev/api/public/audit/$RUN" | jq '.status, .total_score'
    # diff vs the previous run for this site
    curl -s "https://agentfit.dev/api/public/audit/$RUN/diff" | jq '.total_delta'

    # sync (convenience; blocks up to ~30s, set timeout >=120s)
    curl -s --max-time 120 -X POST 'https://agentfit.dev/audit' \
      -H 'Content-Type: application/json' \
      -d '{"base_url":"https://docs.stripe.com"}' | jq '.total_score'
    ```

    ## Rate limits & quota
    - `POST /audit`: ~1 request / 30s per IP (burst 2).
    - A site may be audited at most **once per 24h per network** — re-submitting a
      recently-audited site returns `429`; do NOT retry-loop, read the existing run
      (its share page) or wait for `Retry-After`.
    - Polling/read GETs: ~1 req/s per IP (burst 30). Browse JSON: ~5 req/s (burst 20).
    - Every `429` carries a `Retry-After` header (seconds).

    ## Not documented here (fetch directly from the service)
    Human HTML pages (`/`, `/browse*`, `/r/{id}`, `/rubric`, ...), the share-image
    `/r/{id}/og.png`, operational `/healthz` and `/metrics`, static assets, and
    standardized discovery files (`/llms.txt`, `/llms-full.txt`, `/robots.txt`,
    `/sitemap.xml`, `/index.md`).

    ## Tooling note
    This is an OpenAPI **3.1** document. Use Redoc or a recent Swagger UI; older
    Swagger UI versions do not render 3.1.
  contact:
    name: Stanislav Gumeniuk
    email: i@gumeniuk.com
    url: https://agentfit.dev
  license:
    name: Proprietary
    url: https://agentfit.dev/terms
servers:
  - url: https://agentfit.dev
    description: production
  - url: http://localhost:8080
    description: local development
paths:
  /openapi.yaml:
    get:
      summary: This OpenAPI specification (YAML)
      operationId: openapi
      responses:
        '200':
          description: OK
          content:
            application/yaml:
              schema:
                type: string
  /openapi.json:
    get:
      summary: This OpenAPI specification (JSON)
      operationId: openapiJSON
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
  /audit:
    post:
      summary: Run an AI-readiness audit
      description: |
        Submit a documentation base URL for auditing. Synchronous by default
        (blocks until the report is ready, up to the server's 120s ceiling).
        With `?async=true`, returns `202` immediately with a `poll_url`.
      operationId: audit
      parameters:
        - name: async
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: |
            Opt-in. When true, enqueue the audit and return 202 + poll_url instead
            of blocking. When false/absent, the audit runs synchronously and the
            report is returned at 200. (The first-party web UI sets this to true.)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuditRequest'
            example:
              base_url: https://docs.stripe.com
      responses:
        '200':
          description: |
            Audit completed (synchronous). The body is the AuditReport at the top
            level. `X-Audit-Cache: HIT|MISS` indicates a cached replay;
            `X-Audit-Diagnostics` (base64) carries the non-scoring diagnostics
            envelope when present.
          headers:
            X-Audit-Cache:
              schema: { type: string, enum: [HIT, MISS] }
            X-Audit-Diagnostics:
              schema: { type: string }
              description: Base64-encoded diagnostics envelope (when present).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditReport'
        '202':
          description: |
            Audit accepted (async). Poll `poll_url` until `status=done`. A browser
            form POST (`application/x-www-form-urlencoded`) instead receives a
            303 redirect to the share page `share_url`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AsyncAccepted'
        '400':
          description: Invalid request (malformed body, non-public or non-HTTPS base_url)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Target URL unreachable / audit could not run
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: |
            Rate limit or the 24h-per-site quota was hit. Honour `Retry-After`;
            on a quota hit, read the existing run rather than resubmitting.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds to wait before retrying.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500':
          description: Internal error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '504':
          description: Audit exceeded timeout
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /api/public/audit/{id}:
    get:
      summary: Poll / read a guest audit run
      operationId: getPublicAudit
      description: "Returns the run's current status; the full report is embedded under `report` when `status=done`. Poll every 2-3s until status is `done` or `error`. Always Cache-Control no-store."
      parameters:
        - $ref: '#/components/parameters/RunId'
      responses:
        '200':
          description: Run status (pending/running, error, or done with embedded report)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AuditRunPending'
                  - $ref: '#/components/schemas/AuditRunError'
                  - $ref: '#/components/schemas/AuditRunDone'
                discriminator:
                  propertyName: status
                  mapping:
                    pending: '#/components/schemas/AuditRunPending'
                    running: '#/components/schemas/AuditRunPending'
                    error: '#/components/schemas/AuditRunError'
                    done: '#/components/schemas/AuditRunDone'
        '400':
          description: id is not a UUID
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: Audit not found
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429':
          description: Polling rate limit exceeded
          headers:
            Retry-After: { schema: { type: integer } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /api/public/audit/{id}/history:
    get:
      summary: List prior runs for the same site
      operationId: getPublicAuditHistory
      parameters:
        - $ref: '#/components/parameters/RunId'
        - name: cursor
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: RFC3339 timestamp; returns runs strictly older than this. Defaults to the anchor run's start time. Feed the previous page's next_cursor back here; stop when next_cursor is empty.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: A page of run summaries (newest first)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryPage' }
        '400':
          description: id not a UUID / cursor not RFC3339
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: Audit not found
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /api/public/audit/{id}/diff:
    get:
      summary: Diff a run against a baseline
      operationId: getPublicAuditDiff
      description: Compares the target run (`{id}`) against a baseline. Without `baseline`, auto-selects the most recent prior done run for the same site (`baseline_auto=true`).
      parameters:
        - $ref: '#/components/parameters/RunId'
        - name: baseline
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Run id to diff against. Omit to auto-select the previous done run.
      responses:
        '200':
          description: Per-category and per-criterion deltas
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiffResponse' }
        '400':
          description: id / baseline not a UUID
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: Run not found / no prior done run for auto-baseline
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '422':
          description: Target not done, or baseline invalid (same run / different site / not done)
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  /api/public/browse/summary:
    get:
      summary: Corpus summary (all audited sites)
      operationId: browseSummary
      description: ETag-cached; supports `If-None-Match` → 304.
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: OK
          headers: { ETag: { $ref: '#/components/headers/ETag' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/BrowseSummary' } } }
        '304': { description: Not modified }
  /api/public/browse/top:
    get:
      summary: Top sites by score in a window
      operationId: browseTop
      parameters:
        - name: window
          in: query
          schema: { type: string, enum: [7d, 30d, all], default: 7d }
          description: Look-back window for ranking.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: order
          in: query
          schema: { type: string, enum: [best, worst], default: best }
          description: Rank by highest (best) or lowest (worst) score.
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: OK
          headers: { ETag: { $ref: '#/components/headers/ETag' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/BrowseTop' } } }
        '304': { description: Not modified }
  /api/public/browse/stats:
    get:
      summary: Corpus-wide score / TLD / org distributions
      operationId: browseStats
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: OK
          headers: { ETag: { $ref: '#/components/headers/ETag' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/BrowseStats' } } }
        '304': { description: Not modified }
  /api/public/browse/search:
    get:
      summary: Search audited sites by substring
      operationId: browseSearch
      parameters:
        - name: q
          in: query
          schema: { type: string }
          description: Case-insensitive substring of the site URL.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: OK
          headers: { ETag: { $ref: '#/components/headers/ETag' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/BrowseSearch' } } }
        '304': { description: Not modified }
  /api/public/dashboard:
    get:
      summary: Public dashboard data by slug
      operationId: publicDashboard
      description: Returns a dashboard an operator has marked public. Slugs are operator-provisioned (there is no public listing endpoint).
      parameters:
        - name: slug
          in: query
          required: true
          schema: { type: string }
          description: Operator-provisioned public dashboard slug.
        - name: bucket
          in: query
          schema: { type: string, enum: [30d, 90d, 12m], default: 90d }
          description: Aggregation/look-back bucket for dashboard series.
      responses:
        '200':
          description: Resolved dashboard widgets
          content: { application/json: { schema: { $ref: '#/components/schemas/Dashboard' } } }
        '202':
          description: Cache warming; retry after the Retry-After delay
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string, example: warming } }
        '400':
          description: slug required / invalid bucket
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '403':
          description: Dashboard is not public
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: Dashboard not found
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
components:
  parameters:
    RunId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Run id (UUID) returned by POST /audit?async=true.
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      schema: { type: string }
      description: Conditional request — server returns 304 when the ETag is unchanged.
  headers:
    ETag:
      schema: { type: string }
      description: Content-hash ETag; send it back as If-None-Match to get a 304.
  schemas:
    AuditRequest:
      type: object
      required: [base_url]
      properties:
        base_url:
          type: string
          format: uri
          description: Root URL of the documentation site, no trailing slash. Must be public and (for guest submissions) HTTPS.
          example: https://docs.stripe.com
    AsyncAccepted:
      type: object
      required: [run_id, status, poll_url, share_url]
      description: 202 body for an async audit submission (production DB-backed path).
      properties:
        run_id: { type: string, format: uuid }
        status: { type: string, enum: [pending] }
        poll_url: { type: string, description: "Relative path to GET for status (same as /api/public/audit/{run_id}).", example: /api/public/audit/019ee9a6-6323-7356-b7b2-ce3ed9694290 }
        share_url: { type: string, description: "Relative path to the human-readable HTML share page (not JSON).", example: /r/019ee9a6-6323-7356-b7b2-ce3ed9694290 }
      example:
        run_id: 019ee9a6-6323-7356-b7b2-ce3ed9694290
        status: pending
        poll_url: /api/public/audit/019ee9a6-6323-7356-b7b2-ce3ed9694290
        share_url: /r/019ee9a6-6323-7356-b7b2-ce3ed9694290
    AuditRunPending:
      type: object
      required: [run_id, status, base_url, started_at, poll_url]
      properties:
        run_id: { type: string, format: uuid }
        status: { type: string, enum: [pending, running] }
        base_url: { type: string, format: uri }
        started_at: { type: string, format: date-time }
        poll_url: { type: string }
      example:
        run_id: 019ee9a6-6323-7356-b7b2-ce3ed9694290
        status: running
        base_url: https://docs.stripe.com
        started_at: "2026-05-15T10:02:11Z"
        poll_url: /api/public/audit/019ee9a6-6323-7356-b7b2-ce3ed9694290
    AuditRunError:
      type: object
      required: [run_id, status, base_url, error]
      properties:
        run_id: { type: string, format: uuid }
        status: { type: string, enum: [error] }
        base_url: { type: string, format: uri }
        error: { type: string }
      example:
        run_id: 019ee9a6-6323-7356-b7b2-ce3ed9694290
        status: error
        base_url: https://docs.stripe.com
        error: "target unreachable: dial tcp: i/o timeout"
    AuditRunDone:
      type: object
      required: [run_id, status, base_url, started_at, finished_at, total_score, report, share_url]
      description: |
        Done run. The full report is nested under `report` (note: the synchronous
        POST /audit returns the report at the TOP level instead).
      properties:
        run_id: { type: string, format: uuid }
        status: { type: string, enum: [done] }
        base_url: { type: string, format: uri }
        started_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }
        total_score: { type: integer, minimum: 0, maximum: 100 }
        report: { $ref: '#/components/schemas/AuditReport' }
        share_url: { type: string }
      example:
        run_id: 019ee9a6-6323-7356-b7b2-ce3ed9694290
        status: done
        base_url: https://docs.stripe.com
        started_at: "2026-05-15T10:02:11Z"
        finished_at: "2026-05-15T10:02:39Z"
        total_score: 78
        share_url: /r/019ee9a6-6323-7356-b7b2-ce3ed9694290
        report:
          base_url: https://docs.stripe.com
          audit_date: "2026-05-15"
          auditor: agentfit/v2.31.6
          total_score: 78
          max_score: 100
          # categories + 30 criteria omitted — see the AuditReport schema
    HistoryPage:
      type: object
      required: [runs, next_cursor, base_url, base_url_norm]
      properties:
        runs:
          type: array
          items: { $ref: '#/components/schemas/GuestRunSummary' }
        next_cursor:
          type: string
          description: RFC3339Nano cursor for the next page; empty when no more rows.
        base_url: { type: string, format: uri }
        base_url_norm: { type: string }
      example:
        runs:
          - id: 019ee9a6-6323-7356-b7b2-ce3ed9694290
            started_at: "2026-05-15T10:02:11Z"
            total_score: 78
            status: done
        next_cursor: "2026-05-15T10:02:11.123456Z"
        base_url: https://docs.stripe.com
        base_url_norm: docs.stripe.com
    GuestRunSummary:
      type: object
      required: [id, started_at, total_score, status]
      properties:
        id: { type: string, format: uuid }
        started_at: { type: string, format: date-time }
        total_score: { type: integer }
        # History lists completed runs only; status is always done.
        status: { type: string, enum: [done] }
    DiffResponse:
      type: object
      required: [from_run_id, to_run_id, from_started_at, to_started_at, total_from, total_to, total_delta, categories, criteria, baseline_auto]
      properties:
        from_run_id: { type: string, format: uuid }
        to_run_id: { type: string, format: uuid }
        from_started_at: { type: string, format: date-time }
        to_started_at: { type: string, format: date-time }
        total_from: { type: integer }
        total_to: { type: integer }
        total_delta: { type: integer }
        categories:
          type: array
          items: { $ref: '#/components/schemas/DiffEntry' }
        criteria:
          type: array
          items: { $ref: '#/components/schemas/DiffEntry' }
        baseline_auto:
          type: boolean
          description: True when the baseline was auto-selected (no explicit ?baseline).
      example:
        from_run_id: 019ee990-0000-7000-8000-000000000001
        to_run_id: 019ee9a6-6323-7356-b7b2-ce3ed9694290
        from_started_at: "2026-05-14T09:00:00Z"
        to_started_at: "2026-05-15T10:02:11Z"
        total_from: 74
        total_to: 78
        total_delta: 4
        baseline_auto: true
        categories:
          - { key: C, from: 18, to: 20, delta: 2, from_max: 24, to_max: 24 }
        criteria:
          - { key: C1b, from: 5, to: 7, delta: 2, from_max: 7, to_max: 7, from_status: partial, to_status: present }
    DiffEntry:
      type: object
      required: [key, from, to, delta]
      properties:
        key: { type: string, description: "Category key (A..F) or criterion id (e.g. A1)." }
        from: { type: integer }
        to: { type: integer }
        delta: { type: integer }
        from_max: { type: integer }
        to_max: { type: integer }
        from_status: { type: string, description: "Criteria entries only." }
        to_status: { type: string, description: "Criteria entries only." }
    BrowseSummary:
      type: object
      required: [sites, site_count, refreshed_at]
      properties:
        sites:
          type: array
          items: { $ref: '#/components/schemas/SiteSummary' }
        site_count: { type: integer }
        refreshed_at: { type: string, format: date-time }
    SiteSummary:
      type: object
      required: [base_url, canonical_url, run_count, latest_run_at, first_run_at, best_score, worst_score, avg_score]
      properties:
        base_url: { type: string }
        canonical_url: { type: string, format: uri }
        run_count: { type: integer }
        latest_run_at: { type: string, format: date-time }
        first_run_at: { type: string, format: date-time }
        best_score: { type: integer }
        worst_score: { type: integer }
        avg_score: { type: integer }
    BrowseTop:
      type: object
      required: [window, order, limit, rows]
      properties:
        window: { type: string, enum: [7d, 30d, all] }
        order: { type: string, enum: [best, worst] }
        limit: { type: integer }
        rows:
          type: array
          items: { $ref: '#/components/schemas/TopAudit' }
    TopAudit:
      type: object
      required: [id, base_url, started_at, total_score]
      properties:
        id: { type: string, format: uuid }
        base_url: { type: string }
        started_at: { type: string, format: date-time }
        total_score: { type: integer }
    BrowseStats:
      type: object
      required: [score_bands, tld_distribution, etld_top, site_count, refreshed_at]
      properties:
        score_bands:
          type: array
          items: { $ref: '#/components/schemas/ScoreBand' }
        tld_distribution:
          type: array
          items: { $ref: '#/components/schemas/TLDBucket' }
        etld_top:
          type: array
          items: { $ref: '#/components/schemas/OrgBucket' }
        site_count: { type: integer }
        refreshed_at: { type: string, format: date-time }
    ScoreBand:
      type: object
      required: [band, count, avg_band_score]
      properties:
        band: { type: string, enum: [good, partial, below_avg, low] }
        count: { type: integer }
        avg_band_score: { type: number }
    TLDBucket:
      type: object
      required: [tld, count]
      properties:
        tld: { type: string }
        count: { type: integer }
    OrgBucket:
      type: object
      required: [etld_plus_one, site_count, avg_best, max_best]
      properties:
        etld_plus_one: { type: string }
        site_count: { type: integer }
        avg_best: { type: number }
        max_best: { type: integer }
    BrowseSearch:
      type: object
      required: [query, hits]
      properties:
        query: { type: string }
        hits:
          type: array
          items: { $ref: '#/components/schemas/SiteSummary' }
    Dashboard:
      type: object
      description: Resolved public dashboard. `widgets` is a JSON object keyed by widget id.
      required: [name, widgets]
      properties:
        dashboard_id: { type: string }
        name: { type: string }
        resolved_at: { type: string, format: date-time }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        widgets:
          type: object
          description: Map of widget id → resolved widget. Each value carries a `type` and either `data` or `error`.
          additionalProperties:
            type: object
            properties:
              type:
                type: string
                description: "One of: scorecard, trend, comparison, criterion_heatmap, diff_table, site_grid."
              title: { type: string }
              data: { description: "Widget payload; shape varies by type." }
              error: { type: string, description: "Set instead of data when the widget failed to resolve." }
    AuditReport:
      type: object
      required:
        - base_url
        - audit_date
        - auditor
        - e1_gating_passed
        - unreliable_marker
        - total_score
        - max_score
        - categories
        - criteria
      properties:
        base_url:
          type: string
          format: uri
        audit_date:
          type: string
          format: date
          example: "2026-05-15"
        auditor:
          type: string
          example: agentfit/v2.31.6
        e1_gating_passed:
          type: boolean
          description: True when E1 (no-JS content visibility) scored > 0.
        unreliable_marker:
          type: boolean
          description: True when E1 failed OR when the uniform-shell trap (duplicate bodies across URLs) was detected.
        total_score:
          type: integer
          minimum: 0
          maximum: 100
        max_score:
          type: integer
          enum: [100]
        categories:
          type: object
          required: [A_discovery, B_page_artifacts, C_api_spec, D_content, E_hygiene, F_agent_surface]
          properties:
            A_discovery: { $ref: '#/components/schemas/CategoryScore' }
            B_page_artifacts: { $ref: '#/components/schemas/CategoryScore' }
            C_api_spec: { $ref: '#/components/schemas/CategoryScore' }
            D_content: { $ref: '#/components/schemas/CategoryScore' }
            E_hygiene: { $ref: '#/components/schemas/CategoryScore' }
            F_agent_surface: { $ref: '#/components/schemas/CategoryScore' }
        criteria:
          type: array
          items: { $ref: '#/components/schemas/CriterionResult' }
          minItems: 30
          maxItems: 30
          description: 30 results in canonical order A1, A2, A3, A4, A5, B1…B6, C1a, C1b, C2, C3, D1…D6, E1…E5, F1, F2, F3, F4.
    CategoryScore:
      type: object
      required: [score, max]
      properties:
        score: { type: integer, minimum: 0 }
        max:   { type: integer, minimum: 0 }
    CriterionResult:
      type: object
      required:
        - id
        - status
        - score
        - max
        - evidence_url
        - evidence_snippet
        - http_status
      properties:
        id:
          type: string
          enum:
            - A1
            - A2
            - A3
            - A4
            - A5
            - B1
            - B2
            - B3
            - B4
            - B5
            - B6
            - C1a
            - C1b
            - C2
            - C3
            - D1
            - D2
            - D3
            - D4
            - D5
            - D6
            - E1
            - E2
            - E3
            - E4
            - E5
            - F1
            - F2
            - F3
            - F4
        status:
          type: string
          enum: [present, partial, absent, error, not_applicable]
        score:
          type: integer
          minimum: 0
        max:
          type: integer
          minimum: 0
        evidence_url:
          type: string
          description: URL of the resource that informed this result (may be empty if no useful URL).
        evidence_snippet:
          type: string
          maxLength: 50
          description: Up to 50 runes of representative evidence (snippet, header value, or summary).
        http_status:
          type: integer
          description: HTTP status of the most relevant fetch (0 if no fetch was performed).
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        code:
          type: string
          description: Stable machine-readable error code (added incrementally; may be absent on older paths).
          enum: [invalid_base_url, not_public_address, http_not_supported, rate_limited, site_quota, audit_failed, not_found, not_done, method_not_allowed, internal]
        retry_after:
          type: integer
          description: Seconds to wait before retrying (present on rate-limit/quota errors).
      example:
        error: "this site was already audited from your network within the last 24h; try again later"
        code: site_quota
        retry_after: 43200
