openapi: 3.1.0
info:
  title: Gevell 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.
      10. POST /url/verifyNeedsReview/{jobId} — record the user's verdict
          on a Needs Review finding (nodeId + decision) before editing it.

    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.2.0
  contact:
    name: gevell.com
    url: https://gevell.com
  license:
    name: Proprietary
    url: https://gevell.com/terms

servers:
  - url: https://api.gevell.com/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, or NEEDS_REVIEW_VERIFY_FIRST — the edit touches a Needs Review node with no confirmed-bug verdict; record one via url_verifyNeedsReview first }

  /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 }

  /url/verifyNeedsReview/{jobId}:
    post:
      operationId: url_verifyNeedsReview
      summary: Record the user's verdict on a Needs Review finding
      description: |
        Persist a user-confirmed verdict (confirmed-bug | false-positive |
        wontfix) for one Needs Review node returned by url_result. Verdicts
        are keyed by (user, URL, nodeId) so they survive re-scans — future
        url_result calls surface them as priorVerdict on the matching node;
        do not re-ask the user about previously-decided nodes.
        url_editHtml rejects edits that touch a Needs Review node (422
        NEEDS_REVIEW_VERIFY_FIRST) until that node has a confirmed-bug
        verdict recorded here. Complete the verification flow described in
        url_result first — never call with decision=confirmed-bug without
        explicit user confirmation. Use the per-page jobId from url_result,
        not a crawl-parent jobId.
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyNeedsReviewInput"
      responses:
        "200":
          description: Verdict recorded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_VerifyNeedsReviewResult"
        "400": { description: Invalid nodeId or decision, or crawl-parent jobId }
        "404": { description: Job not found }

  # ── 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 }

  # ── Public PDF Tools (pdfpolish CLI) ────────────────────────────────────────
  # These are mounted under /api (NOT /api/gpt), hence the path-level servers
  # override. Same X-API-Key auth as everything else in this document.

  /pdf/alt-text:
    post:
      operationId: pdf_altText
      summary: Generate alt text for a single image extracted from a PDF
      description: |
        Sends the image to Claude (Haiku-class, via AWS Bedrock) and
        returns WCAG 1.1.1 alt text. decorative=true with an empty alt
        means alt="" is the correct treatment. Decoded image is capped
        at 5 MB. Optional context/pageText improve the suggestion.
      servers:
        - url: https://api.gevell.com/api
          description: Production (public PDF tools base — not /api/gpt)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PdfAltTextInput"
      responses:
        "200":
          description: Alt-text suggestion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_PdfAltTextResult"
        "400": { description: Invalid JSON, unsupported mimeType, malformed base64, or image over 5 MB }
        "401": { description: Invalid or missing API key }
        "429": { description: Rate limit exceeded }
        "502": { description: Alt-text generation failed (model error or unparseable response) }
        "503": { description: Bedrock is not configured in this environment }

  /pdf/validate:
    post:
      operationId: pdf_validate
      summary: Validate a PDF against PDF/UA-1 (veraPDF)
      description: |
        Runs the veraPDF ISO reference validator (PDF/UA-1 profile)
        against the uploaded bytes. Upload as a raw application/pdf body
        or a multipart "file" field, max 50 MB. Only available on the
        Node API host — other runtimes return 503 VERAPDF_UNAVAILABLE.
      servers:
        - url: https://api.gevell.com/api
          description: Production (public PDF tools base — not /api/gpt)
      requestBody:
        required: true
        content:
          application/pdf:
            schema: { type: string, format: binary }
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Validation verdict with per-rule detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_PdfValidateResult"
        "400": { description: Empty upload or not a PDF }
        "401": { description: Invalid or missing API key }
        "413": { description: PDF exceeds the 50 MB limit }
        "429": { description: Rate limit exceeded }
        "503": { description: veraPDF validator unavailable in this environment (code VERAPDF_UNAVAILABLE) }

  # ── Conversion control (#3823) ──────────────────────────────────────────────
  # Mounted under /api/v1 (NOT /api/gpt), hence the path-level servers
  # override. Signed-in user session (Supabase JWT as a Bearer token), not an
  # API key: these are the dashboard's own calls.

  /files/{fileId}/cancel:
    post:
      operationId: pdf_cancelConversion
      summary: Cancel a running conversion and keep the file
      description: |
        Stops the file's conversion in either pipeline and keeps the upload.
        Convert: the job and its unfinished chunks are cancelled, the funds
        hold is released (nothing is charged), a submitted batch job is
        stopped, and the file goes back to "uploaded" so it can be converted
        again. Chunks already converting stop starting new pages within about
        one heartbeat; their output is discarded. Preserve Formatting: the
        job is cancelled and refunded. Idempotent: when nothing is in
        progress the answer is 409 NOT_CANCELLABLE.
      servers:
        - url: https://api-pdf.gevell.com/api/v1
          description: Production (conversion control base — not /api/gpt)
      security:
        - BearerAuth: []
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Conversion cancelled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_CancelConversionResult"
        "401": { description: Not signed in or session expired }
        "404": { description: File not found (or not owned by the caller) }
        "409":
          description: |
            NOT_CANCELLABLE — nothing is in progress (finished, failed or
            already cancelled); refresh to see the current status.
            CONVERSION_FINISHING — the output is already being assembled and
            the conversion can no longer be cancelled.
        "500": { description: CANCEL_FAILED — try again in a moment }

  /files/{fileId}/process-now:
    post:
      operationId: pdf_processNow
      summary: Stop waiting for batch processing and convert now
      description: |
        For a conversion parked for batch processing, moves it to on-demand
        processing now and stops any submitted batch job. If the batch job
        already finished, its results are kept and the job starts with them
        as soon as they are loaded (the same 200 answer). Not batch-waiting:
        409 NOT_BATCH_WAITING.
      servers:
        - url: https://api-pdf.gevell.com/api/v1
          description: Production (conversion control base — not /api/gpt)
      security:
        - BearerAuth: []
      parameters:
        - { name: fileId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Queued for on-demand processing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnvelopeOf_ProcessNowResult"
        "401": { description: Not signed in or session expired }
        "404": { description: File not found (or not owned by the caller) }
        "409": { description: NOT_BATCH_WAITING — the file is not waiting for batch processing; refresh to see its status }
        "500": { description: PROCESS_NOW_FAILED — try again in a moment }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  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 }
        pageCountKnown:
          type: boolean
          description: |
            True when estimatedPages was measured from the PDF's page tree.
            False when the count could not be read (encrypted/malformed) —
            estimatedPages and estimatedWaitSeconds are then omitted.
        estimatedPages: { type: integer, description: "Measured page count. Absent when pageCountKnown is false." }
        estimatedWaitSeconds: { type: integer, description: "Derived from the measured page count. Absent when pageCountKnown is false." }
        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 }
            accessibilityScoreBasis:
              type: string
              enum: [verapdf-checked, heuristic]
              description: >-
                Provenance of accessibilityScore. "heuristic" means only
                structural checks ran — treat the score as unverified, not
                as PDF/UA-1 conformance. "verapdf-checked" means a veraPDF
                PDF/UA-1 run informed the score (see conformance).
            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 }
        needsReview:
          type: array
          description: |
            Lower-confidence findings (false-positive-prone rules + audit
            "incomplete" results). They do NOT affect the score. Verify each
            with the user, then record their verdict via
            url_verifyNeedsReview — do not treat as bugs or pass to
            url_editHtml unverified.
          items:
            type: object
            properties:
              id: { type: string, description: Audit rule id. }
              nodes:
                type: array
                items:
                  type: object
                  properties:
                    selector: {}
                    html: { type: string }
                    nodeId:
                      type: string
                      description: Stable 12-hex handle; pass to url_verifyNeedsReview.
                    priorVerdict:
                      type: object
                      description: Present when the user already decided this node.
                      properties:
                        decision: { type: string, enum: [confirmed-bug, false-positive, wontfix] }
                        decidedAt: { type: string }
                        note: { type: string }
              reviewHint:
                type: object
                properties:
                  falsePositive: { type: string }
                  verify: { type: string }
                  source: { type: string }
        needsReviewCount: { type: integer }
        needsReviewReturned:
          type: integer
          description: When less than needsReviewCount the array was truncated.
        needsReviewDismissedCount: { type: integer }
        needsReviewEnrichmentErrors:
          type: integer
          description: Non-zero when some verdict lookups failed; decided nodes may resurface.
        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 }

    VerifyNeedsReviewInput:
      type: object
      required: [nodeId, decision]
      properties:
        nodeId:
          type: string
          pattern: "^[0-9a-f]{12}$"
          description: Stable node handle from url_result needsReview[].nodes[].nodeId.
        decision:
          type: string
          enum: [confirmed-bug, false-positive, wontfix]
          description: The user-confirmed verdict. Never auto-guess.
        note:
          type: string
          maxLength: 500
          description: Optional audit-trail note; surfaced back as priorVerdict.note.

    VerifyNeedsReviewResult:
      type: object
      properties:
        jobId: { type: string }
        url: { type: string }
        nodeId: { type: string }
        decision: { type: string, enum: [confirmed-bug, false-positive, wontfix] }
        decidedAt: { type: string }
        message: { type: string, description: Guidance on what to do next. }

    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 }

    # ── Public PDF Tools Schemas ───────────────────────────────────────────────

    PdfAltTextInput:
      type: object
      required: [imageBase64, mimeType]
      properties:
        imageBase64:
          type: string
          description: Base64-encoded image bytes. Decoded size must be ≤ 5 MB.
        mimeType:
          type: string
          enum: [image/png, image/jpeg]
        context:
          type: string
          description: Optional caller-supplied context (e.g. figure caption, nearby heading).
        pageText:
          type: string
          description: Optional text extracted from the same PDF page.

    PdfAltTextResult:
      type: object
      properties:
        alt:
          type: string
          description: Suggested alt text. Empty when decorative=true.
        decorative:
          type: boolean
          description: When true, alt="" (artifact) is the correct treatment.
        confidence:
          type: number
          description: Model confidence, 0..1.

    PdfValidateResult:
      type: object
      properties:
        passed: { type: boolean }
        flavour: { type: string, enum: [ua1] }
        totals:
          type: ["object", "null"]
          properties:
            passedRules: { type: integer }
            failedRules: { type: integer }
            passedChecks: { type: integer }
            failedChecks: { type: integer }
        rules:
          type: array
          description: Per-rule detail (the default veraPDF invocation itemises failures only).
          items:
            type: object
            properties:
              clause: { type: string }
              testNumber: { type: string }
              description: { type: string }
              passed: { type: boolean }
              passedChecks: { type: integer }
              failedChecks: { type: integer }
        durationMs: { type: integer }
        parserWarnings:
          type: string
          description: Deduplicated summary of benign parser warnings, when any.
        validatorNote:
          type: string
          description: Present when the validator could not produce rule-level detail.

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

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

    CancelConversionResult:
      type: object
      properties:
        fileId: { type: string }
        status: { type: string, enum: [cancelled] }
        pipeline: { type: string, enum: [convert, preserve], description: "Which pipeline was cancelled" }
        refunded: { type: boolean, description: "A Preserve Formatting charge was refunded" }
      required: [fileId, status, pipeline, refunded]

    ProcessNowResult:
      type: object
      properties:
        fileId: { type: string }
        status: { type: string, enum: [queued] }
      required: [fileId, status]

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

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

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

    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_VerifyNeedsReviewResult:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/VerifyNeedsReviewResult" }

    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: []
