openapi: 3.1.0
info:
  title: TheAccessible.org Audit + PDF
  description: |
    Full accessibility audit and remediation platform.

    Three tool families:

    PDF CONVERSION (pdf_*):
      1. POST /convert — provide fileUrl (public URL) or fileBytes (base64).
         Returns fileId.
      2. Poll GET /status/{fileId} every 12 s until htmlReady + pdfReady
         + conformanceReady are all true.
      3. GET /result/{fileId} — signed download URLs + inline conformance.
      4. (Optional) GET /pdf/html/{fileId} then POST /pdf/html/{fileId}/edit
         to remediate alt text / headings / table semantics; re-poll /status.

    URL SCANNING (url_*):
      1. POST /url/scan — provide url + optional wcagLevel, auth, crawl.
         Returns jobId.
      2. Poll GET /url/status/{jobId} every 10–15 s.
      3. GET /url/result/{jobId} — violations by variant + VPAT + platform hint.
      4. GET /url/conformance/{jobId} — VPAT slice only.
      5. GET /url/html/{jobId}/{variant} — rendered HTML for editing.
      6. POST /url/html/{jobId}/{variant}/edit — apply find/replace edits.
      7. POST /url/rescan/{jobId} — re-enqueue after a fix is deployed.
      8. GET /url/screenshot/{jobId}/{variant} — full-page JPEG (base64).
      9. POST /url/altText/{jobId} — bulk alt-text review + AI suggestions.

    ACR / VPAT GENERATION (acr_*):
      1. GET /acr/queue/{jobId} — WCAG criteria needing human review.
      2. POST /acr/decide — record one verdict.
      3. GET /acr/state/{jobId} — overall progress.
      4. POST /acr/generate — two-call protocol; produces signed ACR HTML+PDF.
      5. POST /acr/altTextReport — two-call protocol; produces alt-text report.
      6. GET /acr/screenshot/{jobId}/{criterionId} — cropped element image.

    Built on a tool registry shared with our MCP HTTP server at /api/mcp.
    Both surfaces expose identical capabilities — operationIds here match
    MCP tool names exactly.
  version: 3.0.0
  contact:
    name: theaccessible.org
    url: https://theaccessible.org
  license:
    name: Proprietary
    url: https://theaccessible.org/terms

servers:
  - url: https://api.theaccessible.org/api/gpt
    description: Production

paths:

  # ── PDF Conversion ──────────────────────────────────────────────────────────

  /convert:
    post:
      operationId: pdf_convert
      summary: Start an accessible-document conversion
      description: |
        Convert a PDF into accessible HTML + tagged PDF + PDF/UA-1
        conformance report. Returns a fileId; poll /status, then call
        /result. Provide exactly one of fileUrl (public URL) or
        fileBytes (base64). /mnt/data paths are NOT URLs — read bytes
        and pass via fileBytes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConvertInput"
      responses:
        "200":
          description: Conversion started.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_ConvertResult"
        "400": { description: Invalid request, file is not a PDF, or both/neither of fileUrl/fileBytes supplied }
        "401": { description: Invalid or missing API key }
        "429": { description: Rate limit exceeded }

  /status/{fileId}:
    get:
      operationId: pdf_status
      summary: Check progress of HTML + PDF + conformance generation
      description: |
        Poll every 12 seconds. Stop polling once htmlReady, pdfReady,
        and conformanceReady are all true. If status becomes "failed",
        stop polling and surface the error to the user.
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Current status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_PdfStatusResult"
        "404": { description: File not found }

  /result/{fileId}:
    get:
      operationId: pdf_result
      summary: Retrieve all deliverables for a completed PDF conversion
      description: |
        Returns html (50KB inline preview + signed downloadUrl), pdf
        (signed downloadUrl + status), conformance (inline PDF/UA-1
        summary with failedRules), and reportUrl. Signed URLs expire in
        1 hour; render them to the user as markdown links.
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Conversion result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_PdfResultPayload"
        "400": { description: Conversion not yet complete }
        "404": { description: File not found }
        "422": { description: Conversion failed }

  /pdf/html/{fileId}:
    get:
      operationId: pdf_getHtml
      summary: Fetch the full accessible HTML for a completed PDF conversion
      description: |
        Returns the complete HTML (no truncation). Use before calling
        pdf_editHtml so you can locate the exact substring to change.
        The HTML is the source of truth for the accessible PDF — editing
        it and regenerating is the preferred remediation path.
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Full HTML.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_PdfGetHtmlResult"
        "400": { description: HTML conversion not yet complete }
        "404": { description: File not found }

  /pdf/html/{fileId}/edit:
    post:
      operationId: pdf_editHtml
      summary: Apply edits to the PDF HTML and regenerate the accessible PDF
      description: |
        Applies find/replace edits (or a full HTML replacement), then
        regenerates the accessible PDF and re-runs PDF/UA-1. Fix alt
        text, heading levels, decorative-image marking, table headers,
        language, or link text. Each find must match exactly once unless
        replaceAll=true. Poll /status afterward.
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EditHtmlInput"
      responses:
        "200":
          description: Edits applied; PDF regeneration started.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_EditHtmlResult"
        "400": { description: HTML not ready or invalid request }
        "404": { description: File not found }
        "409": { description: PDF generation already in progress }
        "422": { description: Edit produced no match or was ambiguous }

  /conformance/{fileId}:
    get:
      operationId: pdf_conformance
      summary: Standalone PDF/UA-1 conformance report (veraPDF)
      description: |
        Returns just the conformance section. Use when only the PDF/UA-1
        verdict matters — e.g. an ACR / VPAT generation workflow. Requires
        that the accessible PDF has finished validating (poll /status until
        conformanceReady).
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Conformance report.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_PdfConformanceOnly"
        "400": { description: Conformance check not yet complete }
        "404": { description: File not found }

  # ── URL Scanning ────────────────────────────────────────────────────────────

  /url/scan:
    post:
      operationId: url_scan
      summary: Start a multi-variant accessibility scan of a live web URL
      description: |
        Renders desktop-light, desktop-dark (when supported), and mobile
        in headless Chromium; runs axe-core + WCAG validator. Returns a
        jobId; poll /url/status. Pass crawl{depth,maxPages} for multi-
        page audits (max 25). Pass auth{cookies,headers} for protected
        pages — transit-only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UrlScanInput"
      responses:
        "200":
          description: Scan queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_UrlScanResult"
        "400": { description: Invalid URL or blocked host }
        "401": { description: Invalid or missing API key }
        "402": { description: Insufficient credits }
        "503": { description: Queue unavailable }

  /url/status/{jobId}:
    get:
      operationId: url_status
      summary: Poll the status of a url_scan / url_rescan job
      description: |
        Returns lightweight provenance (status, url, wcagLevel, timestamps)
        without the full violation payload. For crawl parents, also returns
        pageCount, pagesCompleted, pagesFailed, childJobIds. Call
        url_result once status is "completed".
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Job status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_UrlStatusResult"
        "404": { description: Job not found }

  /url/result/{jobId}:
    get:
      operationId: url_result
      summary: Fetch the full completed scan result for a URL job
      description: |
        Returns title, per-variant violations (desktop, dark, mobile),
        VPAT criteria+score, CMS hint, applied fixes. Crawl parents
        return {kind:"crawl", pages, aggregate} — call url_result on a
        child jobId for full violations. For hosted CMS sites, give
        editor steps instead of raw HTML diffs.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Scan result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_UrlResultPayload"
        "400": { description: Scan not yet complete }
        "404": { description: Job not found }
        "422": { description: Scan failed }

  /url/conformance/{jobId}:
    get:
      operationId: url_conformance
      summary: Fetch just the VPAT conformance slice for a completed URL scan
      description: |
        Returns VPAT criteria, summary, and score plus a CMS/platform hint.
        Use when the caller only wants the WCAG/Section 508 conformance
        verdict without the full violation lists.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Conformance slice.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_UrlConformancePayload"
        "400": { description: Scan not yet complete or VPAT not available }
        "404": { description: Job not found }

  /url/html/{jobId}/{variant}:
    get:
      operationId: url_getHtml
      summary: Fetch the rendered HTML for one variant of a completed URL scan
      description: |
        Returns the captured DOM HTML for desktop, dark, or mobile variant.
        Use before url_editHtml to locate the exact substring to edit.
        The "dark" variant is only available when the scan reported
        supportsDarkMode=true.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
        - { name: variant, in: path, required: true, schema: { type: string, enum: [desktop, dark, mobile] } }
      responses:
        "200":
          description: Rendered HTML.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_UrlGetHtmlResult"
        "400": { description: Scan not yet complete }
        "404": { description: Job not found or variant unavailable }

  /url/html/{jobId}/{variant}/edit:
    post:
      operationId: url_editHtml
      summary: Apply find/replace edits to the captured HTML of a URL scan
      description: |
        Edits the desktop HTML and persists a remediated copy. Does NOT
        modify the live URL — call url_rescan after the user deploys.
        Only use for hand-coded HTML; for hosted CMS (WordPress,
        Squarespace, Webflow, Wix, Shopify, Canvas) walk the user
        through their editor instead.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
        - { name: variant, in: path, required: true, schema: { type: string, enum: [desktop, dark, mobile] } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EditHtmlInput"
      responses:
        "200":
          description: Edits applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_EditHtmlResult"
        "400": { description: Scan not yet complete or invalid request }
        "404": { description: Job not found or HTML missing }
        "422": { description: Edit produced no match or was ambiguous }

  /url/rescan/{jobId}:
    post:
      operationId: url_rescan
      summary: Re-enqueue a fresh scan of the URL from an earlier scan job
      description: |
        Use after the user deploys a fix to verify violations are resolved.
        Authentication from the original scan is NOT replayed — if the page
        requires login, call url_scan directly with a fresh auth payload.
        Returns a new jobId; poll url_status with it.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Rescan queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_UrlRescanResult"
        "404": { description: Original job not found }
        "503": { description: Queue unavailable }

  /url/screenshot/{jobId}/{variant}:
    get:
      operationId: url_getScreenshot
      summary: Fetch the full-page JPEG screenshot for one variant of a URL scan
      description: |
        Returns the screenshot as base64 JPEG inside JSON (not a binary
        download). Useful for visual context or before/after comparisons.
        Older scans return SCREENSHOT_MISSING — call url_rescan to
        populate one.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
        - { name: variant, in: path, required: true, schema: { type: string, enum: [desktop, dark, mobile] } }
      responses:
        "200":
          description: Screenshot as base64.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_ScreenshotResult"
        "404": { description: Job not found, variant unavailable, or screenshot missing }

  /url/altText/{jobId}:
    post:
      operationId: url_altTextBatch
      summary: Bulk alt-text review for a completed URL scan
      description: |
        Classifies every <img> (missingAlt/weakAlt/likelyDecorative/
        looksGood) with an AI suggestion + confidence (Claude Haiku) and
        CMS-specific fix steps. Use for batched alt-text review (5–10
        at a time); narrower than acr_queue. Then call url_editHtml +
        url_rescan.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AltTextBatchInput"
      responses:
        "200":
          description: Alt-text review result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AltTextBatchResult"
        "402": { description: Insufficient credits }
        "404": { description: Job not found or HTML missing }

  # ── ACR / VPAT Generation ───────────────────────────────────────────────────

  /acr/queue/{jobId}:
    get:
      operationId: acr_queue
      summary: Return WCAG criteria needing human review for a URL scan
      description: |
        Returns items with criterion id, page-extracted artifact, and an
        AI pre-grade (verdict, confidence, reasoning) when available.
        High-confidence auto-decisions are pre-recorded and skipped.
        Walk the user through items one at a time and call acr_decide
        per item. Do NOT batch-approve.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Verification queue.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AcrQueueResult"
        "404": { description: Job not found }

  /acr/decide:
    post:
      operationId: acr_decide
      summary: Record the user's decision on a single WCAG criterion
      description: |
        source is determined automatically: "ai-suggested-human-confirmed"
        or "ai-suggested-human-overrode" if an AI pre-grade existed;
        "human-only" otherwise. Always pass the user's actual choice.
        The optional note is the user's rationale; capture it verbatim.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcrDecideInput"
      responses:
        "200":
          description: Decision recorded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AcrDecideResult"
        "404": { description: Job not found }
        "500": { description: Database error }

  /acr/state/{jobId}:
    get:
      operationId: acr_state
      summary: Overall progress of the human-verification queue for a URL scan
      description: |
        Returns total criteria, decided count (split by AI-auto vs human),
        and the list of criteria still pending. Use for "you have N items
        left" summaries at any point in the conversation.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: ACR state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AcrStateResult"
        "404": { description: Job not found }

  /acr/generate:
    post:
      operationId: acr_generate
      summary: Generate the final Accessibility Conformance Report (ACR)
      description: |
        TWO-CALL PROTOCOL: (1) Call WITHOUT signoff → returns
        {needsSignoff:true, prefilled:{name,role,organization,date}};
        present each field to the user for confirmation. (2) Call WITH
        signoff → composes HTML+PDF and returns presigned download URLs
        (~30 min). Surface them as clickable links.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcrGenerateInput"
      responses:
        "200":
          description: Needs signoff OR complete ACR.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AcrGenerateResult"
        "400": { description: Decisions incomplete or job not found }
        "404": { description: Job not found }

  /acr/altTextReport:
    post:
      operationId: acr_altTextReport
      summary: Generate the Alt-Text Remediation Report for a URL scan
      description: |
        TWO-CALL PROTOCOL (same as acr_generate):
        (1) Without signoff — returns prefilled sign-off details.
        (2) With signoff — composes HTML+PDF alt-text remediation report
            from the alt_text_decisions audit trail and returns presigned
            download URLs (~30 min).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcrGenerateInput"
      responses:
        "200":
          description: Needs signoff OR complete report.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AcrAltReportResult"
        "404": { description: Job not found }

  /acr/screenshot/{jobId}/{criterionId}:
    get:
      operationId: acr_getScreenshot
      summary: Fetch a per-element screenshot for a WCAG criterion in the ACR queue
      description: |
        Call when an acr_queue item has screenshot.available===true to
        load the cropped image of the offending element BEFORE asking
        the user to confirm. Returns base64 JPEG in data. Use index to
        walk multiple shots when total>1.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
        - { name: criterionId, in: path, required: true, schema: { type: string }, description: "WCAG criterion id, e.g. 1.1.1" }
        - { name: index, in: query, required: false, schema: { type: integer, minimum: 0 }, description: "Index into the per-criterion screenshot array (default 0)" }
      responses:
        "200":
          description: Element screenshot as base64.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_AcrScreenshotResult"
        "400": { description: Index out of range }
        "404": { description: No screenshots for this criterion }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

  schemas:

    # ── Envelope ──────────────────────────────────────────────────────────────

    Envelope:
      type: object
      properties:
        success: { type: boolean }
      required: [success]

    # ── PDF Conversion Schemas ─────────────────────────────────────────────────

    ConvertInput:
      type: object
      description: Provide exactly one of fileUrl or fileBytes.
      properties:
        fileUrl:
          type: string
          format: uri
          description: Public URL of the PDF (mutually exclusive with fileBytes).
        fileBytes:
          type: string
          description: |
            Base64-encoded PDF bytes (also accepts data:application/pdf;base64,…).
            Mutually exclusive with fileUrl. Max 100 MB decoded.
        fileName:
          type: string
          description: Optional filename override.
        outputs:
          type: array
          description: |
            Subset of deliverables to produce. Defaults to
            ["html","pdf","conformance"]. Requesting "pdf" or "conformance"
            produces both (they share a generation step).
          items:
            type: string
            enum: [html, pdf, conformance]

    ConvertResult:
      type: object
      properties:
        jobId: { type: string }
        fileId: { type: string }
        fileName: { type: string }
        estimatedPages: { type: integer }
        estimatedWaitSeconds: { type: integer }
        message: { type: string }

    PdfStatusResult:
      type: object
      properties:
        fileId: { type: string }
        status:
          type: string
          enum: [uploaded, processing, chunked_processing, validating, fixing, completed, failed]
        progress: { type: integer, minimum: 0, maximum: 100 }
        phase: { type: string }
        htmlReady: { type: boolean }
        pdfStatus:
          type: string
          enum: [none, queued, processing, completed, failed, unavailable]
        pdfReady: { type: boolean }
        conformanceReady: { type: boolean }
        error: { type: string }

    PdfResultPayload:
      type: object
      properties:
        fileId: { type: string }
        fileName: { type: string }
        html:
          type: ["object", "null"]
          properties:
            inlinePreview: { type: string }
            truncated: { type: boolean }
            totalSizeBytes: { type: integer }
            downloadUrl: { type: string, format: uri }
            downloadExpiresAt: { type: string, format: date-time }
        pdf:
          type: object
          properties:
            status:
              type: string
              enum: [none, queued, processing, completed, failed, unavailable]
            downloadUrl: { type: string, format: uri }
            downloadExpiresAt: { type: string, format: date-time }
            sizeBytes: { type: integer }
            accessibilityScore: { type: number }
            error: { type: string }
        conformance:
          oneOf:
            - $ref: "#/components/schemas/PdfConformance"
            - type: "null"
        reportUrl:
          type: string
          format: uri
          description: Browser URL for the full accessibility report. Always include in your reply.

    PdfConformance:
      type: object
      properties:
        standard: { type: string, enum: [PDF/UA-1] }
        passed: { type: boolean }
        rulesEvaluated: { type: integer }
        rulesFailed: { type: integer }
        failedRules:
          type: array
          items:
            type: object
            properties:
              testNumber: { type: string }
              clause: { type: string }
              description: { type: string }
              failedChecks: { type: integer }
        durationMs: { type: integer }
        pdfAccessibilityScore: { type: number }
        error: { type: string }

    EditHtmlInput:
      type: object
      description: Provide exactly one of edits or html.
      properties:
        edits:
          type: array
          description: Ordered list of find/replace edits.
          items:
            type: object
            required: [find, replace]
            properties:
              find:
                type: string
                description: Exact substring to locate (whitespace must match).
              replace:
                type: string
                description: Replacement substring (empty string deletes).
              replaceAll:
                type: boolean
                description: When true, replace every occurrence.
        html:
          type: string
          description: Full replacement HTML document. Use for wholesale rewrites.

    EditHtmlResult:
      type: object
      properties:
        mode:
          type: string
          enum: [edits, replace]
        fileId: { type: string }
        jobId: { type: string }
        editsApplied:
          type: integer
          description: Number of edits applied; always 1 when mode is "replace".
        sizeCharsBefore: { type: integer }
        sizeCharsAfter: { type: integer }
        message: { type: string }

    # ── URL Scanning Schemas ───────────────────────────────────────────────────

    UrlScanInput:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: Public http(s) URL to scan. Private/loopback addresses are rejected.
        wcagLevel:
          type: string
          enum: [A, AA]
          default: AA
          description: WCAG conformance target. Defaults to AA (legal benchmark for ADA Title II).
        auth:
          type: object
          description: Optional authentication for crawling logged-in pages. Transit-only — never persisted.
          properties:
            cookies:
              type: array
              items:
                type: object
                required: [name, value]
                properties:
                  name: { type: string }
                  value: { type: string }
                  domain: { type: string }
                  path: { type: string }
            headers:
              type: object
              additionalProperties: { type: string }
            localStorage:
              type: object
              additionalProperties: { type: string }
        crawl:
          type: object
          description: Optional multi-page crawl settings. Omit for single-page behavior.
          properties:
            depth:
              type: integer
              minimum: 0
              maximum: 1
              default: 0
              description: "0 = single page (default). 1 = crawl sitemap + one hop of links."
            maxPages:
              type: integer
              minimum: 1
              maximum: 25
              default: 10
            sameOriginOnly:
              type: boolean
              default: true
            allowedHosts:
              type: array
              items: { type: string }
            respectRobots:
              type: boolean
              default: true

    UrlScanResult:
      type: object
      properties:
        jobId: { type: string }
        status: { type: string, enum: [pending] }
        pageCount: { type: integer, description: "Set on crawl parents." }
        crawlSource: { type: string }
        message: { type: string }

    UrlStatusResult:
      type: object
      properties:
        jobId: { type: string }
        status: { type: string, enum: [pending, processing, completed, failed] }
        url: { type: string }
        wcagLevel: { type: string, enum: [A, AA] }
        authenticated: { type: boolean }
        createdAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time }
        error: { type: string }
        pageCount: { type: integer }
        pagesCompleted: { type: integer }
        pagesFailed: { type: integer }
        crawlSource: { type: string }
        childJobIds:
          type: array
          items: { type: string }

    UrlResultPayload:
      type: object
      description: Single-page result or crawl aggregate. Check kind field.
      properties:
        jobId: { type: string }
        url: { type: string }
        status: { type: string }
        kind: { type: string, enum: [crawl] }
        title: { type: string }
        wcagLevel: { type: string }
        supportsDarkMode: { type: boolean }
        authenticated: { type: boolean }
        desktop:
          type: object
          properties:
            htmlKey: { type: string }
            violationCount: { type: integer }
            violations: { type: array, items: {} }
        darkDesktop:
          type: object
          properties:
            htmlKey: { type: string }
            violationCount: { type: integer }
            violations: { type: array, items: {} }
        mobile:
          type: object
          properties:
            htmlKey: { type: string }
            violationCount: { type: integer }
            violations: { type: array, items: {} }
        platform:
          type: object
          properties:
            name: { type: string }
            confidence: { type: string, enum: [high, medium, low] }
            hints: { type: array, items: { type: string } }
        vpat:
          type: object
          properties:
            status: { type: string }
            score: { type: ["number", "null"] }
            summary: {}
            criteria: {}
        fixesApplied: { type: array, items: { type: string } }
        completedAt: { type: string }
        pageCount: { type: integer }
        pagesFailed: { type: integer }
        pages:
          type: array
          items:
            type: object
            properties:
              jobId: { type: string }
              url: { type: string }
              status: { type: string }
              score: { type: ["number", "null"] }
              violationsCount: { type: integer }
              error: { type: string }
        aggregate:
          type: object
          properties:
            worstScore: { type: ["number", "null"] }
            totalViolations: { type: integer }

    UrlConformancePayload:
      type: object
      properties:
        jobId: { type: string }
        url: { type: string }
        wcagLevel: { type: string }
        vpat:
          type: object
          properties:
            status: { type: string }
            score: { type: ["number", "null"] }
            summary: {}
            criteria: {}
        platform:
          type: object
          properties:
            name: { type: string }
            confidence: { type: string }

    UrlGetHtmlResult:
      type: object
      properties:
        jobId: { type: string }
        variant: { type: string, enum: [desktop, dark, mobile] }
        html: { type: string }
        sizeBytes: { type: integer }

    UrlRescanResult:
      type: object
      properties:
        jobId: { type: string }
        originalJobId: { type: string }
        status: { type: string, enum: [pending] }
        message: { type: string }

    ScreenshotResult:
      type: object
      properties:
        jobId: { type: string }
        variant: { type: string, enum: [desktop, dark, mobile] }
        mimeType: { type: string, enum: [image/jpeg] }
        data: { type: string, description: Base64-encoded JPEG bytes. }
        sizeBytes: { type: integer }

    AltTextBatchInput:
      type: object
      properties:
        includeStrong:
          type: boolean
          default: true
          description: When false, omit images already classified as looksGood with high-confidence AI suggestion.

    AltTextBatchResult:
      type: object
      properties:
        jobId: { type: string }
        totalImages: { type: integer }
        byCategory:
          type: object
          properties:
            missingAlt: { type: integer }
            weakAlt: { type: integer }
            likelyDecorative: { type: integer }
            looksGood: { type: integer }
        items:
          type: array
          items:
            type: object
            properties:
              src: { type: string }
              currentAlt: { type: ["string", "null"] }
              suggestion: { type: string }
              suggestionConfidence: { type: number }
              decorative: { type: boolean }
              surroundingText: { type: string }
              screenshotKey: { type: ["string", "null"] }
              platformGuidance: { type: ["string", "null"] }
        guidance: { type: string }

    # ── ACR Schemas ────────────────────────────────────────────────────────────

    AcrDecideInput:
      type: object
      required: [jobId, criterionId, verdict]
      properties:
        jobId: { type: string }
        criterionId: { type: string, description: "WCAG criterion id, e.g. 1.1.1" }
        verdict: { type: string, enum: [supports, partial, does-not, na] }
        note: { type: string, maxLength: 2000, description: "Optional human-supplied rationale." }

    AcrGenerateInput:
      type: object
      required: [jobId]
      properties:
        jobId: { type: string }
        signoff:
          type: object
          description: Omit on first call to get prefilled values; include on second call.
          required: [name, organization, date]
          properties:
            name: { type: string }
            role: { type: string }
            organization: { type: string }
            date: { type: string }

    AcrQueueResult:
      type: object
      properties:
        jobId: { type: string }
        total: { type: integer }
        autoDecided: { type: integer }
        remaining: { type: integer }
        items:
          type: array
          items:
            type: object
            properties:
              criterionId: { type: string }
              criterionName: { type: string }
              question: { type: string }
              artifact: {}
              artifactExtracted: { type: boolean }
              aiSuggestion:
                type: ["object", "null"]
                properties:
                  verdict: { type: string, enum: [supports, partial, does-not, na] }
                  confidence: { type: number }
                  reasoning: { type: string }
              screenshot:
                type: ["object", "null"]
                properties:
                  available: { type: boolean }
                  count: { type: integer }
        autoConfidenceThreshold: { type: number }
        guidance: { type: string }

    AcrDecideResult:
      type: object
      properties:
        jobId: { type: string }
        criterionId: { type: string }
        verdict: { type: string, enum: [supports, partial, does-not, na] }
        source: { type: string, enum: [ai-auto, ai-suggested-human-confirmed, ai-suggested-human-overrode, human-only] }

    AcrStateResult:
      type: object
      properties:
        jobId: { type: string }
        total: { type: integer }
        decided: { type: integer }
        autoDecided: { type: integer }
        humanDecided: { type: integer }
        queue:
          type: array
          items:
            type: object
            properties:
              criterionId: { type: string }
              criterionName: { type: string }

    AcrGenerateResult:
      type: object
      description: "First call (no signoff): needsSignoff=true + prefilled. Second call (with signoff): needsSignoff=false + downloads."
      properties:
        jobId: { type: string }
        needsSignoff: { type: boolean }
        prefilled:
          type: object
          properties:
            name: { type: string }
            role: { type: string }
            organization: { type: string }
            date: { type: string }
        message: { type: string }
        signoff:
          type: object
          properties:
            name: { type: string }
            role: { type: string }
            organization: { type: string }
            date: { type: string }
        summary:
          type: object
          properties:
            supports: { type: integer }
            partiallySupports: { type: integer }
            doesNotSupport: { type: integer }
            notApplicable: { type: integer }
            notVerified: { type: integer }
            score: { type: number }
        downloads:
          type: object
          properties:
            html: { type: ["string", "null"], format: uri }
            pdf: { type: string, format: uri }
        warnings:
          type: array
          items: { type: string }

    AcrScreenshotResult:
      type: object
      properties:
        jobId: { type: string }
        criterionId: { type: string }
        index: { type: integer }
        total: { type: integer }
        selector: { type: string }
        boundingBox:
          type: object
          properties:
            x: { type: number }
            y: { type: number }
            w: { type: number }
            h: { type: number }
        mimeType: { type: string, enum: [image/jpeg] }
        data: { type: string, description: Base64-encoded JPEG bytes. }
        sizeBytes: { type: integer }

    # ── Envelope wrappers ─────────────────────────────────────────────────────

    EnvelopeOf_ConvertResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/ConvertResult" }

    EnvelopeOf_PdfStatusResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/PdfStatusResult" }

    EnvelopeOf_PdfResultPayload:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/PdfResultPayload" }

    EnvelopeOf_PdfGetHtmlResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                fileId: { type: string }
                fileName: { type: string }
                html: { type: string }
                sizeBytes: { type: integer }

    EnvelopeOf_PdfConformanceOnly:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                fileId: { type: string }
                conformance: { $ref: "#/components/schemas/PdfConformance" }

    EnvelopeOf_EditHtmlResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/EditHtmlResult" }

    EnvelopeOf_UrlScanResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/UrlScanResult" }

    EnvelopeOf_UrlStatusResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/UrlStatusResult" }

    EnvelopeOf_UrlResultPayload:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/UrlResultPayload" }

    EnvelopeOf_UrlConformancePayload:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/UrlConformancePayload" }

    EnvelopeOf_UrlGetHtmlResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/UrlGetHtmlResult" }

    EnvelopeOf_UrlRescanResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/UrlRescanResult" }

    EnvelopeOf_ScreenshotResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/ScreenshotResult" }

    EnvelopeOf_AltTextBatchResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AltTextBatchResult" }

    EnvelopeOf_AcrQueueResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AcrQueueResult" }

    EnvelopeOf_AcrDecideResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AcrDecideResult" }

    EnvelopeOf_AcrStateResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AcrStateResult" }

    EnvelopeOf_AcrGenerateResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AcrGenerateResult" }

    EnvelopeOf_AcrAltReportResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AcrGenerateResult" }

    EnvelopeOf_AcrScreenshotResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AcrScreenshotResult" }

security:
  - ApiKeyAuth: []
