openapi: 3.1.0
info:
  # Do not change the title, if the title changes, the import paths will be broken
  title: Api
  version: 3.0.0
  description: VulnRap.com API — Vulnerability Report Validation Platform
servers:
  - url: /api
    description: Base API path
tags:
  - name: health
    description: Health operations
  - name: reports
    description: Report submission and analysis
  - name: feedback
    description: User feedback
  - name: stats
    description: Platform statistics
  - name: cohort
    description: Cohort baseline distributions for contextualizing scores
  - name: public
    description: Public-safe transparency endpoints (no reviewer-only fields)
  - name: presets
    description: Curated public preset library (sensitivity + per-engine weights)
  - name: phrase-suggestions
    description: User-suggested handwavy / ai-self-disclosure phrases (Task #634)
  - name: internal
    description: Reviewer-only internal surfaces (gated by CALIBRATION_TOKEN)
  - name: audit
    description: Reviewer audit log of mutation endpoints
  - name: gallery
    description: Curated sample-report gallery for new users
  - name: showcase
    description: Curator-picked "interesting reports" — high-confidence catches, edge cases, surprising agreements
  - name: showcase-nominations
    description: User-submitted showcase nominations for reviewer triage (Task #1048)
  - name: embed
    description: Embeddable public assets (badges, snippets) for third-party triage tools
  - name: webhooks
    description: Reviewer-managed webhook subscriptions (Task #673)
  - name: roadmap
    description: Public roadmap (Now / Next / Later)
  - name: test-yourself
    description: Bring-your-own labeled fixture battery — score user-supplied rows against the live engines and report precision/recall/F1.
paths:
  /webhooks:
    get:
      operationId: listWebhooks
      tags: [webhooks]
      summary: List registered webhooks (reviewer-only)
      description: |
        Returns every registered webhook with its destination URL,
        subscribed events, registration timestamp, last successful
        delivery, and current consecutive-failure count. Reviewer-only —
        the destination URLs and failure counters are operational data we
        do not expose publicly.
      responses:
        "200":
          description: Webhook list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookList"
        "401":
          description: Missing or invalid reviewer token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      operationId: createWebhook
      tags: [webhooks]
      summary: Register a new webhook (reviewer-only)
      description: |
        Registers a destination URL that will receive a signed POST
        whenever a subscribed event fires. v1 only emits the
        `report.scored` event after a successful POST /reports.

        The response body includes a freshly-generated `secret` exactly
        once — only its SHA-256 hash is persisted on the server, so the
        caller MUST capture this value to verify incoming HMAC
        signatures. Re-register the webhook to receive a new secret if
        it is lost.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookCreateBody"
      responses:
        "201":
          description: Webhook registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreateResponse"
        "400":
          description: Invalid URL or unsupported event type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid reviewer token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /webhooks/{id}:
    delete:
      operationId: deleteWebhook
      tags: [webhooks]
      summary: Delete a registered webhook (reviewer-only)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Webhook deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeleteResponse"
        "400":
          description: Invalid webhook id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid reviewer token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /healthz:
    get:
      operationId: healthCheck
      tags: [health]
      summary: Health check
      description: Returns server health status
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthStatus"

  /version:
    get:
      operationId: getVersion
      tags: [health]
      summary: Build / version info
      description: |
        Returns the running build's project version, git SHA, OpenAPI
        spec version, and build timestamp. Self-hosters and hosted-instance
        users can use this to confirm exactly which build they are talking
        to. Stable, public, no authentication required.
      responses:
        "200":
          description: Version info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VersionInfo"

  /reports:
    post:
      operationId: submitReport
      tags: [reports]
      summary: Submit a vulnerability report for analysis
      description: Upload a vulnerability report file for similarity checking and sloppiness scoring
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: The vulnerability report file (.txt, .md, .pdf). Either file or rawText must be provided.
                rawText:
                  type: string
                  description: Plain text content of the vulnerability report. Either file, rawText, or reportUrl must be provided.
                reportUrl:
                  type: string
                  description: HTTPS URL to a plain-text report (GitHub raw, Gist, GitLab, Pastebin, etc.). Auto-converts GitHub blob URLs to raw. Max 5MB.
                contentMode:
                  type: string
                  enum: [full, similarity_only]
                  default: full
                  description: Privacy mode — full shares content, similarity_only stores only hashes
                showInFeed:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Whether to show this report in the public recent reports feed
                skipLlm:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Skip LLM analysis — use only local heuristic/statistical scoring. Sent as string in multipart form data. Forced to "true" server-side when skipRedaction is "true".
                skipRedaction:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Skip PII auto-redaction. When enabled, LLM analysis is automatically disabled to prevent unredacted data from reaching external services. Sent as string in multipart form data.
              required:
                - contentMode
      responses:
        "201":
          description: Report submitted and analyzed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportAnalysis"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "413":
          description: File too large
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/{id}:
    get:
      operationId: getReport
      tags: [reports]
      summary: Get report analysis results
      description: Retrieve the full analysis for a previously submitted report
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Report analysis
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportAnalysis"
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: deleteReport
      tags: [reports]
      summary: Delete a previously submitted report
      description: Permanently deletes a report and all associated data. Requires the delete token returned at submission time.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeleteReportBody"
      responses:
        "200":
          description: Report deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeleteReportResponse"
        "403":
          description: Invalid delete token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/{id}/outcome:
    post:
      operationId: submitReportOutcome
      tags: [reports]
      summary: Record what actually happened to a report
      description: |
        Feeds ground truth back to VulnRap. Send the outcome of your own
        triage (confirmed valid, not valid, duplicate, or needs more
        information) with the delete token returned at submission time. The
        latest answer for a report replaces the previous one. VulnRap compares
        it with the action it recommended at submission and publishes
        aggregate accuracy at `GET /outcomes/summary`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitReportOutcomeBody"
      responses:
        "200":
          description: Outcome recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubmitReportOutcomeResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Invalid delete token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /outcomes/summary:
    get:
      operationId: getOutcomesSummary
      tags: [reports]
      summary: Aggregate accuracy measured against reported outcomes
      description: |
        How often VulnRap's recommendation agreed with what triagers later
        found. Rates are withheld (null) until at least 20 reports with a
        decisive outcome (confirmed valid or not valid) back them.
      responses:
        "200":
          description: Aggregate outcome statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OutcomesSummary"

  /embed/badge.svg:
    get:
      operationId: getEmbedBadgeSvg
      tags: [embed]
      summary: Embeddable score badge SVG (5 styles)
      description: |
        Returns an SVG badge showing the slop tier + score for the report
        identified by `id` (either a numeric id or the public `VR-XXXX` hex
        code). Designed to be dropped into READMEs, advisories, HackerOne /
        Bugcrowd comments, or triage tickets via `<img>` or Markdown.

        Five visual styles are supported via the `style` query param:
        `default` (Shields-style flat with gradient), `flat`, `plastic`,
        `social`, and `square`.

        Always returns 200 OK with a well-formed SVG — unknown / hidden /
        malformed ids render an "unknown" badge so a broken image never
        appears on the embedder's page. Honours `showInFeed`: hidden
        reports render as "unknown" so private scores are not leaked. Sets
        ETag + `Cache-Control: max-age=300` so embedders that re-render
        frequently get cheap 304s.
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: string
          description: Numeric report id, or public `VR-XXXX` hex code.
        - name: style
          in: query
          required: false
          schema:
            type: string
            enum: [default, flat, plastic, social, square]
            default: default
      responses:
        "200":
          description: SVG badge
          content:
            image/svg+xml:
              schema:
                type: string
        "304":
          description: Not modified (matches the `If-None-Match` header)

  /reports/{id}/verify:
    get:
      operationId: getVerification
      tags: [reports]
      summary: Get verification badge data for a report
      description: Returns a lightweight verification summary for embedding in bug reports
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Verification data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerificationBadge"
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/{id}/compare/{matchId}:
    get:
      operationId: compareReports
      tags: [reports]
      summary: Compare two reports side by side
      description: Returns redacted text snippets of both reports for side-by-side similarity comparison
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: matchId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Comparison data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportComparison"
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/{id}/score-history:
    get:
      operationId: getScoreHistory
      tags: [reports]
      summary: Get the chronological score evolution timeline for a report
      description: |
        Returns every recorded composite score for this report — the original
        analysis plus each subsequent backfill rescore. Each entry carries the
        composite score + tier + timestamp + correlation id. Per-engine
        sub-scores are included only for the current scoring event (where
        authentic engine numbers are persisted on the row); past entries
        return `engines: null` because today's analysis_traces schema does
        not persist per-engine numeric scores. `codeVersion` carries the
        semver-style scoring engine version that produced each entry — read
        from the persisted `engine_versions` column for the current score
        and from the `scoringEngineVersion` field on each rescore audit
        entry for historical scores. Null for legacy rows that predate
        version pinning. When the score-stability-monitor task lands a
        dedicated `report_rescore_log` table this endpoint will switch to
        reading from it; today the canonical per-row audit trail is the
        `rescoreHistory` array on the engines blob. Hidden from the UI
        when only a single entry exists (no rescores have happened) so
        fresh reports stay quiet.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Score history timeline
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScoreHistoryResponse"
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /og/result/{id}.png:
    get:
      operationId: getResultOgCard
      tags: [reports]
      summary: Dynamic Open Graph card PNG for a results page
      description: |
        Task #666 — Returns a 1200×630 PNG composed from the report's score,
        tier, top fired evidence signal and timestamp, suitable for use as
        the og:image / twitter:image when sharing /results/:id links.
        Cached for 24h with a content-derived ETag. On any rendering failure
        or unknown / hidden report, redirects to the static site OG image so
        consumers always get something to display.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: 1200×630 PNG card
          headers:
            Cache-Control:
              schema:
                type: string
            ETag:
              schema:
                type: string
          content:
            image/png:
              schema:
                type: string
                format: binary
        "302":
          description: Fallback redirect to the static site OG image
          headers:
            Location:
              schema:
                type: string

  /reports/{id}/triage-report:
    get:
      operationId: getTriageReport
      tags: [reports]
      summary: Get exportable markdown triage report
      description: Returns a formatted markdown summary with score, verification results, evidence, recommendation, and challenge questions — ready to paste into Jira/ServiceNow
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Triage report markdown
          content:
            text/markdown:
              schema:
                type: string
        "404":
          description: Report not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/check:
    post:
      operationId: checkReport
      tags: [reports]
      summary: Check a report against the database without storing it
      description: For report receivers -- analyze a report for similarity and sloppiness without adding it to the database
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: The vulnerability report file (.txt, .md, .pdf)
                rawText:
                  type: string
                  description: Plain text content of the vulnerability report
                reportUrl:
                  type: string
                  description: HTTPS URL to a plain-text report (GitHub raw, Gist, GitLab, Pastebin, etc.). Auto-converts GitHub blob URLs to raw. Max 5MB.
                skipLlm:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Skip LLM analysis — use only local heuristic/statistical scoring. Sent as string in multipart form data. Forced to "true" server-side when skipRedaction is "true".
                skipRedaction:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Skip PII auto-redaction. When enabled, LLM analysis is automatically disabled to prevent unredacted data from reaching external services. Sent as string in multipart form data.
              required: []
      responses:
        "200":
          description: Check results (not stored)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckResult"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/check/dry-run-batch:
    post:
      operationId: checkReportDryRunBatch
      tags: [reports]
      summary: Preview auto-close decisions for a batch of reports
      description: |
        Read-only batch endpoint that runs the same scoring pipeline as
        `POST /reports/check` against an array of report bodies and
        returns, per item, the composite score, the recommended triage
        action, the AVRI gold-signal hit count, and a `wouldAutoClose`
        boolean that mirrors the safety gates in the platform-specific
        recipes (HackerOne, Bugcrowd, etc.). Use this to spot-check what
        the auto-close path *would* have closed across the last week of
        an inbox before flipping auto-close on for real.

        Nothing is persisted — neither the report bodies nor the
        decisions land on the public feed or in the database. LLM
        analysis is forced off so this stays cheap and rate-limit
        friendly. The endpoint shares the analysis rate-limit bucket
        (30 requests / 15 min / IP) with `POST /reports/check`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DryRunBatchBody"
      responses:
        "200":
          description: Per-item dry-run decisions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DryRunBatchResult"
        "400":
          description: Invalid request (empty array, oversize body, etc.)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "413":
          description: A single item exceeds the per-item size cap
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /reports/lookup/{hash}:
    get:
      operationId: lookupByHash
      tags: [reports]
      summary: Look up a report by content hash
      description: Check if a report with the given SHA-256 content hash has been seen before
      parameters:
        - name: hash
          in: path
          required: true
          schema:
            type: string
            minLength: 64
            pattern: "^[a-fA-F0-9]{64}$"
            maxLength: 64
      responses:
        "200":
          description: Hash lookup result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HashLookupResult"

  /reports/feed:
    get:
      operationId: getReportFeed
      tags: [reports]
      summary: Get recent public reports
      description: Returns recent reports that submitters opted to show in the public feed
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: tier
          in: query
          required: false
          schema:
            type: string
          description: Filter by slop tier name (exact match)
        - name: avriFamily
          in: query
          required: false
          schema:
            type: string
            enum:
              - MEMORY_CORRUPTION
              - INJECTION
              - WEB_CLIENT
              - AUTHN_AUTHZ
              - CRYPTO
              - DESERIALIZATION
              - RACE_CONCURRENCY
              - REQUEST_SMUGGLING
              - FLAT
          description: Filter by cached AVRI rubric family id (exact match against reports.avri_family).
        - name: fabricatedEvidence
          in: query
          required: false
          schema:
            type: string
            enum:
              - fake_raw_http
              - stripped_trace
              - either
          description: |
            Filter the feed to AVRI Engine 2 fabricated-evidence cohorts. `fake_raw_http`
            keeps only rows where signalBreakdown.avri.rawHttp.isFake is true,
            `stripped_trace` keeps only rows where signalBreakdown.avri.crashTrace.isStripped
            is true, and `either` keeps rows that match either flag. Mirrors the
            fakeRawHttp / strippedCrashTrace booleans surfaced on each feed row.
        - name: inferredCwe
          in: query
          required: false
          schema:
            type: string
          description: |
            Filter by soft-citation inferred CWE id (e.g. `CWE-79`). Only reports
            where the CWE Coherence Checker engine inferred a matching CWE are
            returned. The value is derived from the vulnrap_engine_results JSONB
            blob at query time (signalBreakdown.avri.softCitation.inferredCwe or
            signalBreakdown.softCitation.inferredCwe).
        - name: fusionVersion
          in: query
          required: false
          schema:
            type: string
          description: |
            Filter by fusion engine version (exact match against engine_versions->>'fusion').
            For example `3.10.0`. Use GET /reports/feed/engine-versions
            to discover available values.
        - name: linguisticVersion
          in: query
          required: false
          schema:
            type: string
          description: |
            Filter by linguistic engine version (exact match against engine_versions->>'linguistic').
            For example `3.10.0`. Use GET /reports/feed/engine-versions to discover available values.
        - name: substanceVersion
          in: query
          required: false
          schema:
            type: string
          description: |
            Filter by substance engine version (exact match against engine_versions->>'substance').
            For example `3.10.0`. Use GET /reports/feed/engine-versions to discover available values.
        - name: cweVersion
          in: query
          required: false
          schema:
            type: string
          description: |
            Filter by CWE engine version (exact match against engine_versions->>'cwe').
            For example `3.10.0`. Use GET /reports/feed/engine-versions to discover available values.
        - name: avriVersion
          in: query
          required: false
          schema:
            type: string
          description: |
            Filter by AVRI engine version (exact match against engine_versions->>'avri').
            For example `3.10.0`. Use GET /reports/feed/engine-versions to discover available values.
        - name: sort
          in: query
          required: false
          schema:
            type: string
            enum: [newest, oldest, score_asc, score_desc]
            default: newest
      responses:
        "200":
          description: Recent public reports
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportFeed"

  /reports/feed/engine-versions:
    get:
      operationId: getReportFeedEngineVersions
      tags: [reports]
      summary: List distinct engine versions in the public feed
      description: |
        Returns the distinct version strings for every scoring engine (fusion,
        linguistic, substance, cwe, avri) present across all public feed
        reports, each sorted in descending semver order. The result is briefly
        cached (60 s) so the dropdowns stay cheap.
      responses:
        "200":
          description: Distinct engine versions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedEngineVersions"

  /feedback/challenge:
    get:
      operationId: getFeedbackChallenge
      tags: [feedback]
      summary: Get a proof-of-work challenge for feedback submission
      description: Returns a SHA-256 proof-of-work challenge that must be solved before submitting feedback. This prevents automated spam.
      responses:
        "200":
          description: Challenge issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackChallenge"

  /feedback:
    post:
      operationId: submitFeedback
      tags: [feedback]
      summary: Submit user feedback about the tool
      description: Allows users to share whether the tool was helpful and suggest improvements. Requires a solved proof-of-work challenge.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitFeedbackWithChallenge"
      responses:
        "201":
          description: Feedback submitted successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Challenge verification failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/analytics:
    get:
      operationId: getFeedbackAnalytics
      tags: [feedback]
      summary: Get aggregated feedback analytics
      description: Returns summary stats, rating distribution, daily trends, score-vs-feedback correlation, outlier reports, and recent feedback entries
      responses:
        "200":
          description: Feedback analytics data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackAnalytics"

  /feedback/holdout-eval:
    get:
      operationId: getHoldoutEval
      tags: [feedback]
      summary: Honest precision/recall computed from the locked holdout split (Task #640)
      description: |
        Returns binary-classifier precision/recall/F1/accuracy computed
        ONLY from the deterministic 20% feedback holdout that calibration
        suggestions never touch, alongside the same numbers computed from
        the in-sample 80%. Treats the engine as a binary "slop" classifier
        where `predicted slop = slopScore >= tierThresholds.high` and
        `actually slop = rating <= 2 OR helpful = false`. Each partition
        returns nullable precision/recall/F1/accuracy so the UI can render
        "insufficient data" instead of a misleading 0 when a partition is
        empty.
      responses:
        "200":
          description: Holdout evaluation
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HoldoutEvalResponse"

  /audit-log:
    get:
      operationId: getAuditLog
      tags: [audit]
      summary: List recorded reviewer mutations
      description: |
        Task #645 — Reviewer-only paginated read of the `audit_log` table
        populated by the audit-log middleware on every mutation route.
        Strict reviewer auth required (sends reviewer identities and IPs).
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: actor
          in: query
          required: false
          schema:
            type: string
          description: Exact-match filter on the `actor` column.
        - name: method
          in: query
          required: false
          schema:
            type: string
            enum: [POST, PUT, PATCH, DELETE]
          description: Exact-match HTTP method filter (case-insensitive).
        - name: endpoint
          in: query
          required: false
          schema:
            type: string
          description: Substring filter (case-insensitive) on the endpoint URL.
        - name: from
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound on `createdAt` (ISO 8601).
        - name: to
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive upper bound on `createdAt` (ISO 8601).
      responses:
        "200":
          description: Audit log page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditLogResponse"
        "401":
          description: Reviewer auth required
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration:
    get:
      operationId: getCalibrationReport
      tags: [feedback]
      summary: Get calibration report with tuning suggestions
      description: Analyzes feedback data against scoring results to suggest weight and threshold adjustments. Uses volume gating to ensure suggestions are based on sufficient data.
      responses:
        "200":
          description: Calibration report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CalibrationReport"

  /feedback/calibration/avri-drift:
    get:
      operationId: getAvriDriftReport
      tags: [feedback]
      summary: Get rolling AVRI calibration drift report
      description: |
        Rolling weekly view of the AVRI composite for production reports,
        bucketed by triage outcome (T1-equivalent vs T3-equivalent). Surfaces
        per-family means and drift flags (rubric collapse and per-family
        weight drift) so reviewers can spot when the AVRI rubric needs
        re-tuning.
      parameters:
        - in: query
          name: weeks
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 26
          description: How many weeks of history to scan (default 8, capped at 26).
      responses:
        "200":
          description: AVRI drift report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvriDriftReport"

  /feedback/calibration/avri-drift/notifications:
    get:
      operationId: getAvriDriftNotifications
      tags: [feedback]
      summary: List the persisted AVRI drift notification dedup state
      description: |
        Task #196 — Returns the per-flag dedup records that have already been
        dispatched via `POST /feedback/calibration/avri-drift/notify` (or the
        in-process auto-trigger). Each entry would silently suppress its
        corresponding flag on the next dispatch run unless re-armed via
        `/feedback/calibration/avri-drift/notifications/rearm`. The endpoint
        is auth-gated (strict) because the records include the original flag
        `detail` string.
      responses:
        "200":
          description: Persisted dedup state snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvriDriftNotificationsList"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/avri-drift/notifications/rearm:
    post:
      operationId: rearmAvriDriftNotifications
      tags: [feedback]
      summary: Re-arm one or more previously-notified AVRI drift flags
      description: |
        Task #196 — Removes one or more entries from the AVRI drift
        notification dedup state by `key` so the next dispatch run treats
        the matching flags as never-seen and re-fires the webhook. Useful
        for the "acknowledged-but-not-fixed by the fix-by date" workflow,
        where reviewers want a flag re-paged after the fix window lapses
        without hand-editing `data/avri-drift-notifications.json`.

        Unknown keys are reported back via `notFound` (mirroring the
        per-phrase removal pattern); the request returns 200 when at least
        one entry was re-armed and 404 only when nothing matched.

        The optional `reviewer` and `rationale` body fields feed the
        bounded re-arm audit log persisted alongside the dedup state
        so multi-reviewer teams can see who re-armed which flag and why.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AvriDriftRearmBody"
      responses:
        "200":
          description: At least one entry was re-armed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvriDriftRearmResponse"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: No entries matched any of the supplied keys
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvriDriftRearmResponse"

  /feedback/calibration/avri-drift/scheduler-status:
    get:
      operationId: getAvriDriftSchedulerStatus
      tags: [feedback]
      summary: Get per-replica AVRI drift scheduler status
      description: |
        Operator-visible status of the AVRI drift-notification scheduler
        across every replica that has registered a heartbeat. Backs the
        calibration page's per-replica heartbeat table so reviewers can
        confirm every replica's timer is firing — and spot a wedged
        replica hiding behind a healthy one — without scraping logs.

        Unauthenticated, matching `/feedback/calibration/auth-status`:
        the response is timestamps + booleans + replica identity only
        (no error text, webhook URL, or token).

        The response is an array because the API server runs in a
        multi-replica deploy. Each entry is keyed by `replicaId` (a
        stable per-process identity, e.g. `${hostname}-${bootHex}`) so
        the calibration UI can render one row per replica. The replica
        that handled the request returns a live in-memory snapshot;
        peer replicas return their last-persisted heartbeat from the
        shared `data/avri-drift-notifications.json` state file.
      responses:
        "200":
          description: Per-replica scheduler status snapshots
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AvriDriftSchedulerStatus"

  /internal/shadow-drift:
    get:
      operationId: getShadowDrift
      tags: [internal]
      summary: Shadow scoring drift report (reviewer-only, Task #639)
      description: |
        Reviewer-only listing of production reports whose shadow score
        diverges from the live score by ≥1 tier or ≥10 score points
        within the lookback window. Backs the "Shadow drift" tab on
        `/feedback-analytics`. Strict-auth: shadow drift is a leading
        signal of an in-flight scoring regression, same access policy
        as the other reviewer-only drift surfaces.
      parameters:
        - name: lookbackDays
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 14
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Shadow drift report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShadowDriftReport"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/score-stability:
    get:
      operationId: getScoreStabilitySummary
      tags: [feedback]
      summary: Score-stability tier-flip summary (reviewer-only, Task #620)
      description: |
        Reviewer-only summary of the nightly score-stability monitor.
        Aggregates `report_rescore_log` rows from the last 7 days into
        per-day flip counts broken down by direction (legit→slop,
        slop→legit, tightened, loosened, lateral) plus the day's
        overall flip-rate. Backs the tier-flip chart on
        `/feedback-analytics`. Strict-auth: an elevated flip-rate is a
        leading indicator of an active scoring regression, same access
        policy as the other reviewer-only drift surfaces.
      responses:
        "200":
          description: Score stability summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScoreStabilitySummary"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/score-stability/flips:
    get:
      operationId: getScoreStabilityFlips
      tags: [feedback]
      summary: List individual tier flips for a specific day (Task #944)
      description: |
        Reviewer-only drilldown listing the individual reports whose tier
        flipped on a given day, with old/new tier, old/new score, flip
        direction, and report ID for deep-linking to the report detail
        view. Backs the "investigate" flow on the score-stability chart.
      parameters:
        - name: date
          in: query
          required: true
          schema:
            type: string
            pattern: "^\\d{4}-\\d{2}-\\d{2}$"
          description: ISO date `YYYY-MM-DD` (UTC) to list flips for.
      responses:
        "200":
          description: Day flip listing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScoreStabilityDayFlips"
        "400":
          description: Missing or invalid date parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/score-stability/scheduler-status:
    get:
      operationId: getScoreStabilitySchedulerStatus
      tags: [feedback]
      summary: Per-replica score-stability scheduler heartbeat (Task #620)
      description: |
        Operator-visible status of the in-process score-stability
        scheduler for the responding replica. Mirrors the AVRI drift /
        rescore-backfill scheduler-status endpoints: timestamps +
        booleans + small numeric counters only, so the endpoint stays
        safe to expose unauthenticated alongside the other heartbeat
        surfaces.
      responses:
        "200":
          description: Score stability scheduler status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScoreStabilitySchedulerStatus"

  /feedback/calibration/nvd-rejected-feed/scheduler-status:
    get:
      operationId: getNvdRejectedFeedSchedulerStatus
      tags: [feedback]
      summary: NVD rejected-CVE feed refresher heartbeat (Task #1338)
      description: |
        Operator-visible status of the in-process NVD rejected-CVE feed
        refresher for the responding replica. Mirrors the AVRI drift /
        rescore-backfill / score-stability scheduler-status endpoints:
        timestamps + booleans + small numeric counters only, so the
        endpoint stays safe to expose unauthenticated alongside the
        other heartbeat surfaces.
      responses:
        "200":
          description: NVD rejected-feed scheduler status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NvdRejectedFeedSchedulerStatus"

  /feedback/calibration/scoring-gate-runs:
    get:
      operationId: getScoringGateRuns
      tags: [feedback]
      summary: Scoring-gate flip-rate history (reviewer-only, Task #974)
      description: |
        Returns the last N scoring-gate replay runs, each with its
        timestamp, commit, total reports, flip count, flip rate, and the
        top per-fixture diffs. Backs a sparkline + table on the
        calibration dashboard so reviewers can spot calibration drift
        over time.
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 30
          description: Number of most recent runs to return (default 30, max 100).
      responses:
        "200":
          description: Scoring gate run history
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScoringGateRunHistory"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: clearScoringGateRuns
      tags: [feedback]
      summary: Clear scoring-gate run history (reviewer-only, Task #974)
      description: |
        Deletes all stored scoring-gate replay run records. Use after
        an intentional re-baselining to reset the trend line.
      responses:
        "200":
          description: History cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  cleared:
                    type: boolean
                required: [cleared]
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/avri-drift/notifications/rearm-history:
    get:
      operationId: getAvriDriftRearmHistory
      tags: [feedback]
      summary: List the persisted AVRI drift re-arm audit log
      description: |
        Returns the bounded audit log of every reviewer-driven re-arm
        event recorded by
        `POST /feedback/calibration/avri-drift/notifications/rearm`. The
        log is capped at 200 entries (oldest trimmed first). Strict-auth
        because the entries include the original flag `detail` plus
        reviewer/rationale context.
      responses:
        "200":
          description: Persisted re-arm audit log
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AvriDriftRearmHistoryList"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /presets:
    get:
      operationId: listPresets
      tags: [presets]
      summary: List curated scoring presets
      description: |
        Task #631 — Returns the curated preset library (sensitivity profiles
        plus per-engine weights). Sourced from a static JSON file shipped
        with the api-server; safe to cache for an hour. Each preset has an
        opaque `id` that the `/check` page accepts via `?preset=<id>` to
        load the configuration into local settings.
      responses:
        "200":
          description: Curated preset library
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PresetLibrary"

  /roadmap:
    get:
      operationId: listRoadmap
      tags: [roadmap]
      summary: List public roadmap items (Now / Next / Later)
      description: |
        Task #693 — Returns the public roadmap rendered on `/roadmap`.
        Sourced from a static JSON file shipped with the api-server so
        curators can edit `roadmap.json` and bump `version` without a
        redeploy of the schema. Items carry a `column` (now | next |
        later), a `status` badge, a one-line description, and an
        optional fuzzy ETA. The response is intentionally illustrative
        — the page header reminds visitors it is not a contractual
        commitment.
      responses:
        "200":
          description: Public roadmap
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoadmapResponse"

  /gallery:
    get:
      operationId: listGallery
      tags: [gallery]
      summary: List curated sample reports
      description: |
        Task #647 — Returns the curated sample-report gallery rendered on
        `/gallery`. Sourced from a static JSON file shipped with the
        api-server; safe to cache for an hour. Each sample carries the
        display fields the gallery card needs (title, snippet, score,
        top signals, label) plus a `reportId` pointing at a seed report
        so click-through resolves on `/results/:id`.
      responses:
        "200":
          description: Curated sample-report gallery
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GalleryResponse"

  /incidents:
    get:
      operationId: listIncidents
      tags: [public]
      summary: List public incident postmortems
      description: |
        Task #707 — Returns the public incident log rendered on
        `/incidents`. Sourced from a static JSON file shipped with the
        api-server (curator-edited only — there is no admin/write
        endpoint). Each entry documents one operational incident
        (engine outage, scoring regression, calibration mistake) with
        date, duration, severity, summary, root cause, remediation,
        and an optional `/changelog#anchor` link to the corresponding
        release notes. Returns an empty array when no incidents have
        been recorded.
      responses:
        "200":
          description: Public incident log
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IncidentLog"
    post:
      operationId: createIncident
      tags: [internal]
      summary: Publish a new incident postmortem (reviewer-only)
      description: |
        Task #1057 — Reviewer-gated endpoint that appends a new
        incident postmortem to the incident log. Requires the
        `CALIBRATION_TOKEN` via `X-Calibration-Token` header or
        `Authorization: Bearer <token>`. Validates the body against
        the `IncidentEntry` schema, auto-bumps the `version` patch
        number, and records an audit-log entry.
      parameters:
        - name: X-Calibration-Token
          in: header
          required: true
          schema:
            type: string
          description: Reviewer calibration token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateIncidentBody"
      responses:
        "201":
          description: Incident published successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateIncidentResponse"
        "401":
          description: Missing or invalid reviewer token
        "409":
          description: Duplicate incident ID

  /showcase:
    get:
      operationId: listShowcase
      tags: [showcase]
      summary: List curator-picked interesting reports
      description: |
        Task #694 — Returns the hand-curated "interesting reports"
        rendered on `/showcase`: high-confidence catches, edge cases,
        and surprising engine agreements. Sourced from a static JSON
        file (`data/showcase.json`) shipped with the api-server; safe
        to cache for an hour. Each entry carries a redacted excerpt,
        the composite score + tier, a one-paragraph "why this is
        interesting" note, and a `reportId` pointing at a seed report
        so click-through resolves on `/results/<reportId>`.
      responses:
        "200":
          description: Curated showcase entries
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShowcaseResponse"

  /public/showcase-nominations:
    post:
      operationId: submitShowcaseNomination
      tags: [showcase-nominations]
      summary: Nominate a report for the showcase
      description: |
        Task #1048 — Public endpoint that lets anonymous users nominate a
        report for the curated showcase. Submissions are queued
        (status="pending") and never auto-published. Rate-limited to 5
        successful submissions per IP per 24 hours; same-IP duplicates
        for the same reportId in the last 24h return 200 with
        `duplicate: true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ShowcaseNominationSubmitBody"
      responses:
        "200":
          description: Duplicate nomination (already queued in the last 24h)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShowcaseNominationSubmitResponse"
        "201":
          description: Nomination accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShowcaseNominationSubmitResponse"
        "400":
          description: Invalid submission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Daily limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShowcaseNominationRateLimited"

  /feedback/calibration/showcase-nominations:
    get:
      operationId: listShowcaseNominations
      tags: [showcase-nominations]
      summary: List queued showcase nominations (reviewer-only)
      description: |
        Task #1048 — Returns the nomination queue for reviewer triage.
        Defaults to status=pending. Requires the calibration token.
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [pending, approved, rejected]
            default: pending
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Nominations list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShowcaseNominationList"
        "401":
          description: Missing or invalid calibration token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/showcase-nominations/{id}:
    patch:
      operationId: updateShowcaseNominationStatus
      tags: [showcase-nominations]
      summary: Mark a nomination approved or rejected (reviewer-only)
      description: |
        Task #1048 — Updates the status of a queued showcase nomination.
        Approval adds the report to the curated showcase; rejection
        discards the nomination.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ShowcaseNominationPatchBody"
      responses:
        "200":
          description: Status updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShowcaseNominationPatchResponse"
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid calibration token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Nomination not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /public/drift-summary:
    get:
      operationId: getPublicDriftSummary
      tags: [public]
      summary: Public-safe AVRI drift summary for the transparency page
      description: |
        Task #617 — Public, read-only summary of the rolling weekly
        T1-vs-T3 mean composite spread. Wraps the internal AVRI drift
        compute and strips every reviewer-only field (cohort sample IDs,
        per-family means, flag detail strings, thresholds,
        bucketing-note, runbook path, raw bucket counts) so the
        `/transparency` page can show that the platform actively monitors
        itself for calibration drift without exposing internal triage
        signals.

        Returns up to the last 12 eligible weeks of `{ weekStart,
        spread }` data points plus a current-vs-previous-week delta. If
        no weekly aggregate has been computed yet, `weeks` is `[]` and
        the current/previous/delta fields are `null` so the widget can
        render an empty state.
      responses:
        "200":
          description: Public drift summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicDriftSummary"

  /feedback/calibration/auth-brute-force-alerts:
    get:
      operationId: getCalibrationAuthBruteForceAlerts
      tags: [feedback]
      summary: List recent calibration auth brute-force alert decisions
      description: |
        Task #399 — Bounded snapshot of the in-process ring buffer of
        dispatched calibration-auth brute-force alerts so reviewers can
        confirm at a glance whether the latest alert was them
        fat-fingering a token or a real probe, without round-tripping
        through pino logs.

        The entries mirror the webhook payload (sans the constant
        `event` discriminator and the static `recommendedActions`
        runbook copy). The buffer is in-memory only — restarts clear it
        and a multi-replica deploy reflects whichever replica handled
        the request. Strict-auth because the entries include client IP
        plus per-route metadata.
      parameters:
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
          description: |
            Maximum number of recent alerts to return (newest first).
            Defaults to 10 and is clamped server-side to the buffer
            size (50). Values that fail to parse as a positive integer
            return 400.
      responses:
        "200":
          description: Recent alert snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CalibrationAuthBruteForceAlertsList"
        "400":
          description: Invalid limit query parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/auth-brute-force-alerts/ack:
    post:
      operationId: ackCalibrationAuthBruteForceAlert
      tags: [feedback]
      summary: Acknowledge a calibration auth brute-force alert
      description: |
        Task #749 — Mark the alert identified by `(ip, detectedAt)` as
        acknowledged so the next reviewer to land on the calibration
        page can tell signal from noise. The optional `reviewer` and
        `note` body fields populate an in-memory audit trail attached
        to the same ring-buffer entry the recent-alerts panel renders;
        an alert evicted by FIFO churn (or a process restart) takes
        its ack with it, matching the buffer's "in-process only" model.

        Returns 404 when no matching alert is in the ring buffer (it
        was already evicted, or the API server restarted) and 409 when
        a different reviewer has already acknowledged the row — in the
        409 case the response includes the existing `ack` so the UI
        can display who got there first instead of just toasting an
        error. Strict-auth because the response echoes the alert's
        IP / last-route metadata.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CalibrationAuthBruteForceAlertAckBody"
      responses:
        "200":
          description: Alert acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CalibrationAuthBruteForceAlertAckResponse"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Reviewer token missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: No matching alert in the in-process ring buffer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Alert was already acknowledged by another reviewer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CalibrationAuthBruteForceAlertAckConflict"

  /feedback/calibration/config:
    get:
      operationId: getScoringConfig
      tags: [feedback]
      summary: Get current and historical scoring configurations
      description: Returns the active scoring config version and full version history.
      responses:
        "200":
          description: Scoring config
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScoringConfigResponse"

  /feedback/calibration/auth-status:
    get:
      operationId: getCalibrationAuthStatus
      tags: [feedback]
      summary: Probe whether the reviewer token is configured/valid
      description: |
        Task #117 — Returns boolean signals describing whether the API server
        requires a reviewer token for calibration mutations and whether the
        caller's request would be accepted by `requireCalibrationAuth`. This
        endpoint is intentionally NOT auth-gated so the dashboard can render
        a "Reviewer token: configured / missing / invalid" indicator BEFORE
        the reviewer triggers a mutation that 401s. The configured token is
        never echoed in the response — only boolean signals.
      responses:
        "200":
          description: Auth status snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CalibrationAuthStatus"

  /feedback/calibration/apply:
    post:
      operationId: applyCalibration
      tags: [feedback]
      summary: Apply calibration changes to scoring config
      description: Creates a new scoring configuration version with the specified changes. All subsequent reports will be scored with the new config.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ApplyCalibrationBody"
      responses:
        "201":
          description: Config updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApplyCalibrationResponse"
        "400":
          description: Invalid changes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/handwavy-phrases:
    get:
      operationId: getHandwavyPhrases
      tags: [feedback]
      summary: List the curated FLAT hand-wavy marker phrases
      description: |
        Returns the active list of FLAT-family hand-wavy marker phrases used by
        AVRI Engine 2 to apply the slop haircut. Reviewers can extend this list
        through POST/DELETE without an engineer redeploying the service.
      responses:
        "200":
          description: Active hand-wavy phrase list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhrasesList"
    post:
      operationId: addHandwavyPhrase
      tags: [feedback]
      summary: Append a new FLAT hand-wavy marker phrase
      description: |
        Appends a phrase to the curated FLAT hand-wavy marker list. Phrases are
        normalized to lowercase + collapsed whitespace before storage. Subsequent
        triages pick the new phrase up immediately — no redeploy required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseBody"
      responses:
        "200":
          description: Phrase already present (no change)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
        "201":
          description: Phrase appended
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
        "400":
          description: Invalid phrase
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    patch:
      operationId: editHandwavyPhrase
      tags: [feedback]
      summary: Edit a curated FLAT hand-wavy marker phrase in place
      description: |
        Updates the `category`, `rationale`, and/or the phrase string itself
        of an existing curated marker without losing the original add audit
        context. Each edit appends a `HandwavyEditEntry` to the marker's
        `edits` log recording who made the change, when, and the
        before/after for any field that actually changed.

        Task #247 — when `newPhrase` is provided AND its normalized form
        (lowercase + collapsed whitespace) differs from the existing
        phrase, the marker's row identity is rewritten in place to the new
        phrase. The marker keeps its original `addedBy`/`addedAt`/
        `rationale` and the rename is recorded as a `phrase` before/after
        change on the appended audit entry. Renaming to a normalized form
        already used by another active phrase is rejected so the active
        list stays a set.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseEditBody"
      responses:
        "200":
          description: Edit applied (or no-op if updates matched the existing values)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
        "400":
          description: Invalid request body or update values
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Phrase not in the list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: removeHandwavyPhrase
      tags: [feedback]
      summary: Remove one or many FLAT hand-wavy marker phrases
      description: |
        Removes a phrase (matched after lowercase + whitespace normalization)
        from the curated FLAT hand-wavy marker list. Task #135 — accepts
        either a single phrase (`{phrase}`) or a batch (`{phrases: [...]}`)
        in one round-trip; the batch path appends one history entry that
        lists every removed phrase instead of N separate entries.

        Task #145 — when the batch body has `dryRun: true`, the server
        returns a preview (corpus impact + per-phrase outcomes) and does
        NOT mutate the active list, history, or cache.

        Task #155 — the single-phrase body also accepts `dryRun: true`
        and returns the same corpus + production removal-impact summary
        (with `batch: false`) so the in-UI Trash flow can warn reviewers
        before a one-click removal lands.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/HandwavyPhraseBody"
                - $ref: "#/components/schemas/HandwavyPhraseBatchRemoveBody"
      responses:
        "200":
          description: |
            Phrase removed (single path), batch processed and at least one
            phrase was removed (batch path; see `batch: true` and `results`),
            single-phrase dry-run preview (`dryRun: true`, `batch: false`),
            OR batch dry-run preview (`dryRun: true`, `batch: true`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
                  - $ref: "#/components/schemas/HandwavyPhraseBatchRemoveResponse"
                  - $ref: "#/components/schemas/HandwavyPhraseBatchRemoveDryRunResponse"
                  - $ref: "#/components/schemas/HandwavyPhraseSingleRemoveDryRunResponse"
        "400":
          description: Invalid phrase / batch body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: |
            Phrase not in the list (single path) OR batch matched zero phrases
            (batch path returns the same `results` shape as 200).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ErrorResponse"
                  - $ref: "#/components/schemas/HandwavyPhraseBatchRemoveResponse"

  /feedback/calibration/handwavy-phrases/removal-batches:
    get:
      operationId: listHandwavyPhraseRemovalBatches
      tags: [feedback]
      summary: List recent FLAT hand-wavy phrase BATCH removal entries (picker-friendly summary)
      description: |
        Task #160 — Slim summary of the most recent batch removal entries from
        the hand-wavy phrase removal-history log, intended for the
        reinstate-batch CLI's interactive picker. Single-phrase removal
        entries are omitted because they're not reinstatable through the
        /reinstate-batch endpoint. Newest entries are returned first; default
        page size is 10, capped at 50 via the `limit` query parameter.
      parameters:
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
          description: Maximum number of batch entries to return (newest first). Defaults to 10, capped at 50.
      responses:
        "200":
          description: Recent batch removal summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseRemovalBatchesList"
        "400":
          description: Invalid query parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/handwavy-phrases/removal-batches/{removedAt}:
    get:
      operationId: getHandwavyPhraseRemovalBatch
      tags: [feedback]
      summary: Fetch the FULL inner phrase list for a single batch removal entry
      description: |
        Task #176 — Sibling detail endpoint for the picker list above. Returns
        every inner phrase from one batch removal entry (not just the
        5-phrase sample the list endpoint surfaces) plus the per-phrase
        audit metadata (category, original adder, per-phrase reinstated
        flag). The reinstate-batch CLI calls this to render a "preview"
        step between the picker / `--removed-at` flag and the final
        confirmation prompt; Task #244 wires the same endpoint into the
        web reviewer's picker so reviewers see every phrase before
        confirming.

        Single-phrase removal entries return 404 with `reason: "not-a-batch"`
        — they are not reinstatable through /reinstate-batch and the caller
        should fall back to /reinstate.
      parameters:
        - in: path
          name: removedAt
          required: true
          schema:
            type: string
            format: date-time
          description: ISO 8601 timestamp of the parent batch removal entry. Must be URL-encoded by the caller.
      responses:
        "200":
          description: Full inner phrase list with per-phrase audit metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseRemovalBatchDetail"
        "400":
          description: Missing or empty removedAt path parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: |
            No removal-history entry matches the supplied removedAt
            (`reason: "history-not-found"`) or the matched entry is a
            single-phrase removal that's not addressable through this
            endpoint (`reason: "not-a-batch"`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseRemovalBatchDetailError"

  /feedback/calibration/handwavy-phrases/undo:
    post:
      operationId: undoHandwavyPhrase
      tags: [feedback]
      summary: Undo a brand-new add of a FLAT hand-wavy marker phrase
      description: |
        Task #130 — mirror of /reinstate. A reviewer who just added a phrase
        can press Undo within a short server-side window (default 5 minutes)
        and the marker is removed. The resulting history row is tagged
        `undone: true` so the audit trail clearly records "added then undone"
        rather than producing an unrelated manual-removal entry. The active
        marker is matched by `phrase` + `addedAt`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseUndoBody"
      responses:
        "200":
          description: Phrase undone
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
        "400":
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: No matching active marker (or curated default)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: addedAt mismatch or undo window expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/handwavy-phrases/undo-batch:
    post:
      operationId: undoHandwavyPhrasesBatch
      tags: [feedback]
      summary: Undo every still-in-window FLAT hand-wavy phrase add in one round-trip
      description: |
        Task #233 — bulk wrapper around POST /feedback/calibration/handwavy-phrases/undo.
        The reviewer's UI tracks every reviewer-added phrase that is still
        inside its per-marker undo window (default 5 minutes) and pressing
        "Undo last N adds" sends every `(phrase, addedAt)` pair in one
        round-trip. The server walks each entry through the per-marker
        undo path so each successful undo still appends its own
        `undone: true` history row — the audit contract is NOT collapsed
        into a single batch row, so per-phrase provenance is preserved
        for downstream tooling. Per-entry failures (`not-found`,
        `addedAt-mismatch`, `window-expired`, `no-addedAt`) are reported
        in the per-phrase `results` array instead of failing the whole
        call so a reviewer who fires the button right as one entry's
        window elapses still gets the rest rolled back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseUndoBatchBody"
      responses:
        "200":
          description: Batch processed (see `undoneCount` and per-phrase `results`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseUndoBatchResponse"
        "400":
          description: Invalid body (missing / malformed `entries`, oversized batch, or non-string `reviewer`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/handwavy-phrases/reinstate-batch:
    post:
      operationId: reinstateHandwavyPhrasesBatch
      tags: [feedback]
      summary: Reinstate every not-yet-reinstated phrase from a single batch removal entry
      description: |
        Task #144 — single-round-trip reinstate of every inner phrase from one
        Task #135 batch removal entry. The reviewer supplies the parent
        entry's `removedAt`; the server reinstates each remaining inner
        phrase, records the current reviewer on each one, and flips the
        aggregate `reinstated` flag once everything is back. Inner phrases
        that have already been reinstated (partial undo earlier) or that
        have been re-added to the active list separately are skipped with a
        reason instead of failing the whole call. Per-phrase reinstate via
        the existing /reinstate endpoint still works for partial undos.

        Task #159 — when `dryRun: true` is sent in the body, the server
        returns the same `results` / `reinstatedCount` / `skipped` shape
        (with `dryRun: true` and `batch: true` flags) without mutating
        the active marker list or appending to the removal-history log.
        Reviewers can preview exactly which inner phrases would be
        reinstated and which would be skipped before pressing yes.
        Mirrors the dry-run pattern used by the bulk-remove endpoint:
        both the mutating and dry-run paths return HTTP 200 and callers
        discriminate on the `dryRun` flag in the body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseReinstateBatchBody"
      responses:
        "200":
          description: |
            Batch processed (mutating path; see `reinstatedCount` and
            `results`) OR dry-run preview when `dryRun: true` was sent
            (`dryRun: true` and `batch: true` flags, no state was mutated).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/HandwavyPhraseReinstateBatchResponse"
                  - $ref: "#/components/schemas/HandwavyPhraseReinstateBatchDryRunResponse"
        "400":
          description: Invalid body (missing or non-string `removedAt`/`reviewer`, or non-boolean `dryRun`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: No matching removal-history entry for that removedAt.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: The matched history entry exists but is a single-phrase removal (use /reinstate instead).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/handwavy-phrases/reinstate:
    post:
      operationId: reinstateHandwavyPhrase
      tags: [feedback]
      summary: Reinstate a previously removed FLAT hand-wavy marker phrase from history
      description: |
        Task #121 — re-adds a phrase straight from the removal-history log so a
        reviewer doesn't have to retype it (and its rationale) into the add
        form. The history entry is matched by `phrase` + `removedAt`. The
        marker is added with the original category and rationale; the CURRENT
        reviewer is recorded as `addedBy` and a fresh `addedAt` timestamp is
        used so the audit trail still shows who reinstated and when. The
        history row is then flagged `reinstated: true` so the same row cannot
        be reinstated twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseReinstateBody"
      responses:
        "201":
          description: Phrase reinstated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
        "400":
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: No matching history entry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Already reinstated or already in the active list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/handwavy-phrases/revert-edit:
    post:
      operationId: revertHandwavyPhraseEdit
      tags: [feedback]
      summary: Revert a single edit on a curated FLAT hand-wavy marker phrase
      description: |
        Task #132 — undoes a single entry from a curated phrase's `edits` log
        in one click. The reviewer supplies the phrase and the `editedAt`
        timestamp of the edit they want to undo; the server restores
        whatever fields that entry recorded a change to (category and/or
        rationale) back to their `from` values, leaving any field the entry
        did NOT change alone. The audit log stays append-only — the revert
        appears as a fresh `HandwavyEditEntry` with the inverse before/after
        pair, attributed to the reviewer who pressed the button. A no-op
        revert (current values already match the target) returns
        `edited: false` and does not append a new entry.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HandwavyPhraseRevertEditBody"
      responses:
        "200":
          description: Revert applied (or no-op when current values already match)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HandwavyPhraseMutationResponse"
        "400":
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Phrase or matching edit entry not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /stats:
    get:
      operationId: getStats
      tags: [stats]
      summary: Get platform statistics
      description: Returns aggregate statistics for the platform
      responses:
        "200":
          description: Platform statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformStats"

  /stats/recent:
    get:
      operationId: getRecentActivity
      tags: [stats]
      summary: Get recent submission activity
      description: Returns a summary of recent report submissions
      responses:
        "200":
          description: Recent activity
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecentActivity"

  /stats/distribution:
    get:
      operationId: getSlopDistribution
      tags: [stats]
      summary: Get slop score distribution
      description: Returns the distribution of slop scores across all reports
      responses:
        "200":
          description: Score distribution
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SlopDistribution"

  /stats/visit:
    post:
      operationId: recordVisit
      tags: [stats]
      summary: Record a page visit
      description: Records a privacy-respecting unique visitor count using hashed identifiers
      responses:
        "200":
          description: Visit recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VisitRecorded"

  /stats/visitors:
    get:
      operationId: getVisitorStats
      tags: [stats]
      summary: Get visitor statistics
      description: Returns total unique visitors and total visits
      responses:
        "200":
          description: Visitor statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VisitorStats"

  /cohort/baseline:
    get:
      operationId: getCohortBaseline
      tags: [cohort]
      summary: Get cohort baseline distribution
      description: |
        Returns a 10-bucket histogram of VulnRap composite scores submitted in
        the last 7 days plus the median score for that cohort. The composite
        score is the same metric shown in the big number on the results page
        header, so the percentile derived from these bins is a valid
        percentile for that displayed score. When `cwe` is supplied, the
        histogram and median are restricted to reports whose cached AVRI rubric
        family matches the requested CWE family — otherwise the platform-wide
        cohort is returned. Reports with no cached composite (legacy rows from
        before the multi-engine consensus) are excluded from the cohort.
      parameters:
        - name: cwe
          in: query
          required: false
          schema:
            type: string
          description: |
            Optional cached AVRI rubric family id (e.g. INJECTION,
            MEMORY_CORRUPTION) to restrict the cohort. Unknown values fall
            through to the platform-wide cohort.
        - name: metric
          in: query
          required: false
          schema:
            type: string
            enum: [composite, slop]
            default: composite
          description: |
            Which per-report score axis to bucket the cohort against.
            `composite` (default) uses the VulnRap composite score shown in
            the results page header. `slop` uses the legacy AI Detection
            ("slop") score that still appears further down the results page,
            so the same baseline ribbon UI can contextualise that legacy
            number too. When `metric=slop` the engineMedians block is null
            (engine medians are only meaningful for the composite cohort).
        - name: score
          in: query
          required: false
          schema:
            type: number
            minimum: 0
            maximum: 100
          description: |
            Optional composite score to compute an exact percentile rank
            for. When supplied, the response includes a `percentile` field
            computed from the underlying rows (mid-rank convention) so
            callers do not have to approximate from the 10-bucket
            histogram.
      responses:
        "200":
          description: Cohort baseline distribution
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CohortBaseline"

  /public/cwe-catalog:
    get:
      operationId: getCweCatalog
      tags: [public]
      summary: Public CWE family catalog the engine recognizes
      description: |
        Task #663 — Returns every CWE the scoring engine has a fingerprint
        for, grouped by AVRI rubric family (INJECTION, MEMORY_CORRUPTION,
        ...). Each entry carries the CWE id, friendly name, family, alias
        list, expected vocabulary the engine looks for, MITRE link, a short
        "what a high-quality report for this class looks like" summary
        derived from the family rubric, the historical rejection-rate
        multiplier, per-CWE usage stats (count + average composite score
        across the corpus, computed from the soft-citation `inferredCwe`
        on `vulnrap_engine_results`), and a deep link into the reports
        feed filtered to that family.
      responses:
        "200":
          description: CWE catalog
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CweCatalogResponse"

  /public/corpus-stats:
    get:
      operationId: getCorpusStats
      tags: [stats]
      summary: Get aggregate corpus statistics
      description: |
        Returns at-a-glance aggregates over the platform's full scoring corpus —
        total reports, breakdown by tier, top 10 most-fired evidence signals,
        top 10 CWE families, and a daily volume time series. All values are
        already-aggregated counts; no individual report content is exposed.
      responses:
        "200":
          description: Corpus statistics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CorpusStats"

  /public/latency-snapshot:
    get:
      operationId: getLatencySnapshot
      tags: [stats]
      summary: Get 24h scoring latency snapshot
      description: |
        Public p50/p95/p99 latency for the end-to-end scoring pipeline plus a
        per-engine breakdown, computed from the last 24h of analysis_traces.
        Returns the histogram bins for the full pipeline and each engine, the
        percentile values, and an optional worst-engine call-out when one
        engine is dragging the tail (its p95 is more than 1.5x the median
        engine p95 and contributes >25% of pipeline p95).
      responses:
        "200":
          description: Latency snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LatencySnapshot"

  /public/latency-history:
    get:
      operationId: getLatencyHistory
      tags: [stats]
      summary: Get daily p95 latency over time
      description: |
        Returns daily p50/p95/p99 latency for the end-to-end scoring pipeline
        and each engine over the last N days (default 14). Useful for spotting
        latency trends and engine regressions.
      parameters:
        - name: days
          in: query
          required: false
          schema:
            type: integer
            default: 14
            minimum: 1
            maximum: 90
      responses:
        "200":
          description: Daily latency history
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LatencyHistory"

  /public/status:
    get:
      operationId: getPublicStatus
      tags: [stats]
      summary: Public status / uptime snapshot
      description: |
        Task #706 — Public status page snapshot. Returns:
          * 30-day API uptime % (fraction of last 30 days that recorded
            at least one successful scoring trace).
          * 24h p50/p95 end-to-end pipeline latency, in milliseconds.
          * Per-engine subsystem health (linguistic, substance, CWE,
            AVRI, LLM gate) derived from the most recent traces — each
            engine is marked operational/degraded/down/unknown based on
            how recently its pipeline stage was last seen successfully.
          * An overall banner status — operational if every engine is
            operational, degraded if any engine is degraded, and down
            if any engine is fully down.
        Cached publicly for 60 seconds.
      responses:
        "200":
          description: Status snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicStatusSnapshot"

  /public/status/incidents:
    get:
      operationId: getPublicStatusIncidents
      tags: [stats]
      summary: Recent incident history
      description: |
        Task #1055 — Returns detected incidents (degradations / outages)
        derived from analysis_traces telemetry for the last 30 days.
        A contiguous window where an engine's pipeline stage was missing
        for more than one hour becomes an incident. Each incident carries
        a start time, end time, duration, severity, and the list of
        affected engines.
      responses:
        "200":
          description: Incident history
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicStatusIncidentsResponse"

  /stats/trends:
    get:
      operationId: getTrends
      tags: [stats]
      summary: Get trend data over time
      description: Returns daily report volume, tier breakdown, average score, and feedback trends for the specified time window
      parameters:
        - name: days
          in: query
          required: false
          schema:
            type: integer
            minimum: 7
            maximum: 365
            default: 90
          description: Number of days to look back
      responses:
        "200":
          description: Trend data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TrendsData"

  /public/phrase-suggestions:
    post:
      operationId: submitPhraseSuggestion
      tags: [phrase-suggestions]
      summary: Submit a user-suggested handwavy / ai-self-disclosure phrase
      description: |
        Task #634 — Public endpoint that lets anonymous end users propose new
        phrases for reviewer triage. Submissions are queued (status="pending")
        and never auto-applied. Rate-limited to 5 successful submissions per
        IP per 24 hours; same-IP duplicates of the same normalized text in
        the last 24h return 200 with `duplicate: true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PhraseSuggestionSubmitBody"
      responses:
        "200":
          description: Duplicate submission (already queued in the last 24h)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhraseSuggestionSubmitResponse"
        "201":
          description: Suggestion accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhraseSuggestionSubmitResponse"
        "400":
          description: Invalid submission
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Daily limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhraseSuggestionRateLimited"

  /feedback/calibration/phrase-suggestions:
    get:
      operationId: listPhraseSuggestions
      tags: [phrase-suggestions]
      summary: List queued user-suggested phrases (reviewer-only)
      description: |
        Task #634 — Returns the suggestion queue for reviewer triage.
        Defaults to status=pending. Requires the calibration token.
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [pending, approved, rejected]
            default: pending
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Suggestions list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhraseSuggestionList"
        "401":
          description: Missing or invalid calibration token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /feedback/calibration/phrase-suggestions/{id}:
    patch:
      operationId: updatePhraseSuggestionStatus
      tags: [phrase-suggestions]
      summary: Mark a suggestion approved or rejected (reviewer-only)
      description: |
        Task #634 — Updates the status of a queued suggestion. The actual
        addition to the active phrase list happens via the existing
        /feedback/calibration/handwavy-phrases (or
        /feedback/calibration/ai-self-disclosure-phrases) endpoints; this
        is just the bookkeeping flip.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PhraseSuggestionPatchBody"
      responses:
        "200":
          description: Status updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhraseSuggestionPatchResponse"
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid calibration token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Suggestion not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /test-yourself/run:
    post:
      operationId: runTestYourself
      tags: [test-yourself]
      summary: Score a user-supplied labeled fixture battery
      description: |
        Bring-your-own fixture battery for power users (PSIRT teams,
        security platforms) who want to validate the engine against
        their own labeled corpus before adopting it.

        Accepts up to 50 `{text, label}` rows where `label` is the
        user's expected verdict (`valid` for a real, well-substantiated
        report; `invalid` for slop / fabrication / noise). Each row is
        run through the same composite scoring pipeline used by
        `POST /reports`, the predicted label is derived from the
        composite score (composite >= 50 → `valid`, otherwise
        `invalid`), and an aggregate confusion matrix +
        precision / recall / F1 against the user-supplied labels is
        returned alongside per-row results.

        Synchronous; nothing is persisted; no PII redaction is applied
        to the returned per-row text excerpt (the caller already has
        the text). IP rate-limited to 10 runs / day. The "valid" class
        is treated as the positive class for precision / recall.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TestYourselfRunBody"
      responses:
        "200":
          description: Battery scored
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestYourselfRunResponse"
        "400":
          description: Invalid request body (missing rows, too many rows, bad labels, etc.)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Daily rate limit exceeded for this IP
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  schemas:
    AuditRevertHint:
      type: object
      description: |
        Task #645 — Static "revert this mutation" hint stitched onto each
        audit-log entry by the read endpoint. Null when the entry's
        endpoint has no known reverse operation. Task #1004 — `payload`
        carries the ready-to-send reverse request body reconstructed
        server-side from the original request payload; null when the
        original payload was empty or reconstruction failed.
      properties:
        method:
          type: string
        endpoint:
          type: string
        description:
          type: string
        payload:
          type: object
          nullable: true
          additionalProperties: true
          description: Ready-to-send reverse request body. Null when reconstruction is not possible.
      required: [method, endpoint, description]

    AuditLogEntry:
      type: object
      description: |
        Task #645 — One reviewer mutation captured by the audit-log
        middleware. `requestPayload` is the JSON body that hit the
        server with secret-shaped keys (token/secret/password/...)
        replaced by `[REDACTED]`. `responseStatus` is the final HTTP
        status the reviewer's request received.
      properties:
        id:
          type: integer
        actor:
          type: string
          description: Reviewer name (from body.reviewer or X-Reviewer-Name) or "anonymous".
        method:
          type: string
        endpoint:
          type: string
        requestPayload:
          nullable: true
          description: Redacted JSON body. Null for empty / multipart bodies.
        queryParams:
          nullable: true
          description: Redacted query-string parameters. Null when none were sent.
        responseStatus:
          type: integer
        ip:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
        revertHint:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/AuditRevertHint"
      required:
        [id, actor, method, endpoint, responseStatus, createdAt, revertHint]

    AuditLogResponse:
      type: object
      description: |
        Task #645 — Paginated reviewer audit log response. `entries` is
        ordered newest-first.
      properties:
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        entries:
          type: array
          items:
            $ref: "#/components/schemas/AuditLogEntry"
      required: [total, limit, offset, entries]

    HealthStatus:
      type: object
      properties:
        status:
          type: string
      required:
        - status

    VersionInfo:
      type: object
      description: |
        Version metadata for the running build. `project` is the human
        product name, `projectVersion` is the SemVer of the running
        build (matches the root `package.json#version`), `gitSha` is
        the git commit the build was cut from (or `"dev"` for an
        un-tagged local build), `specVersion` is the SemVer of the
        OpenAPI contract this server is serving (matches
        `lib/api-spec/openapi.yaml#info.version`), and `builtAt` is
        an ISO-8601 timestamp of when the build was produced.
      properties:
        project:
          type: string
        projectVersion:
          type: string
        gitSha:
          type: string
        specVersion:
          type: string
        builtAt:
          type: string
          format: date-time
      required:
        - project
        - projectVersion
        - gitSha
        - specVersion
        - builtAt

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
      required:
        - error

    ScoreHistoryEntry:
      type: object
      description: |
        One scoring event in a report's evolution timeline. The first entry is
        the original analysis; subsequent entries each correspond to a
        backfill rescore that rewrote the composite (drawn from the row's
        rescoreHistory audit trail).
      properties:
        compositeScore:
          type: integer
          description: Composite score 0-100 captured at this point in time.
        label:
          type: string
          nullable: true
          description: Composite tier label captured at this point in time. Null for very old rows that pre-date the label column.
        recordedAt:
          type: string
          format: date-time
          description: ISO timestamp of when this score was recorded (report.createdAt for the original entry; rescoredAt for rescore entries).
        correlationId:
          type: string
          nullable: true
          description: Correlation id of the analysis trace that produced this score, if any. Reconstruction-mode rescores use a `recon-…` prefix.
        source:
          type: string
          enum: [original, backfill-rescore]
          description: Where this entry came from. `original` = first analysis; `backfill-rescore` = a bulk rescore overwrote the row.
        mode:
          type: string
          enum: [original, engine, reconstruction]
          description: For rescore entries, which branch produced the new score (live engine re-run vs. reconstruction from cached signals). Always `original` for the first entry.
        codeVersion:
          type: string
          nullable: true
          description: Semver-style scoring engine version that produced this score (e.g. "3.10.0"). Read from the persisted engine_versions for the current entry, or from the rescore audit entry for historical scores. Null for legacy rows that predate version pinning.
        engines:
          type: array
          nullable: true
          description: Per-engine sub-scores captured at this point. Sourced from the engines blob for the current entry, or from the matching analysis trace for past entries. Null when no per-engine data is available for that scoring event.
          items:
            type: object
            properties:
              engine:
                type: string
              score:
                type: integer
              verdict:
                type: string
                nullable: true
              confidence:
                type: string
                nullable: true
            required:
              - engine
              - score
      required:
        - compositeScore
        - recordedAt
        - source
        - mode

    ScoreHistoryResponse:
      type: object
      properties:
        reportId:
          type: integer
        entries:
          type: array
          description: Chronological (oldest-first) list of every recorded composite score for this report. Empty when no vulnrap composite has been computed yet.
          items:
            $ref: "#/components/schemas/ScoreHistoryEntry"
      required:
        - reportId
        - entries

    SimilarityMatch:
      type: object
      properties:
        reportId:
          type: integer
        similarity:
          type: integer
          description: Similarity percentage (0-100)
        matchType:
          type: string
          enum: [near-duplicate, high-similarity, structural, semantic]
      required:
        - reportId
        - similarity
        - matchType

    SectionMatchItem:
      type: object
      properties:
        sectionTitle:
          type: string
        matchedReportId:
          type: integer
        matchedSectionTitle:
          type: string
        similarity:
          type: integer
      required:
        - sectionTitle
        - matchedReportId
        - matchedSectionTitle
        - similarity

    RedactionSummary:
      type: object
      properties:
        totalRedactions:
          type: integer
        categories:
          type: object
          additionalProperties:
            type: integer
      required:
        - totalRedactions
        - categories

    DeleteReportBody:
      type: object
      properties:
        deleteToken:
          type: string
          description: The delete token returned when the report was submitted
      required:
        - deleteToken

    SubmitReportOutcomeBody:
      type: object
      properties:
        deleteToken:
          type: string
          description: The delete token returned when the report was submitted
        outcome:
          type: string
          enum: [confirmed_valid, not_valid, duplicate, needs_info]
          description: What triage concluded about the report
        note:
          type: string
          maxLength: 500
          description: Optional free-text context (not published)
      required:
        - deleteToken
        - outcome

    SubmitReportOutcomeResponse:
      type: object
      properties:
        reportId:
          type: integer
        outcome:
          type: string
          enum: [confirmed_valid, not_valid, duplicate, needs_info]
        predictedAction:
          type: string
          nullable: true
          description: The action VulnRap recommended at submission, when recorded
        agreed:
          type: boolean
          nullable: true
          description: Whether the outcome agreed with the recommendation; null when it cannot be judged
      required:
        - reportId
        - outcome
        - predictedAction
        - agreed

    OutcomesSummary:
      type: object
      properties:
        totalOutcomes:
          type: integer
        decisiveOutcomes:
          type: integer
          description: Outcomes that are confirmed_valid or not_valid
        byOutcome:
          type: object
          additionalProperties:
            type: integer
        queuePrecision:
          type: number
          nullable: true
          description: Of reports VulnRap sent to a human, the share confirmed valid
        queueSample:
          type: integer
        missedRealRate:
          type: number
          nullable: true
          description: Of reports VulnRap challenged, closed or quarantined, the share confirmed valid
        heldBackSample:
          type: integer
        minimumSample:
          type: integer
      required:
        - totalOutcomes
        - decisiveOutcomes
        - byOutcome
        - queuePrecision
        - queueSample
        - missedRealRate
        - heldBackSample
        - minimumSample

    DeleteReportResponse:
      type: object
      properties:
        message:
          type: string
      required:
        - message

    ReportAnalysis:
      type: object
      properties:
        id:
          type: integer
        deleteToken:
          type: string
          description: Secret token for deleting this report. Only returned on initial submission. Store it — it cannot be recovered.
        contentHash:
          type: string
        contentMode:
          type: string
          enum: [full, similarity_only]
        slopScore:
          type: integer
          description: AI slop likelihood score 0-100 (higher = more likely AI-generated)
        slopTier:
          type: string
          description: Human-readable sloppiness tier
        qualityScore:
          type: integer
          description: Report quality/completeness score 0-100 (higher = better quality). Separate from slopScore — a terse real report can have low quality but also low slop.
        confidence:
          type: number
          format: float
          description: Confidence in the slopScore (0.0-1.0). Low confidence means limited signals were available.
        breakdown:
          $ref: "#/components/schemas/ScoreBreakdown"
        evidence:
          type: array
          items:
            $ref: "#/components/schemas/EvidenceItem"
          description: Specific signals found during analysis with their weights
        similarityMatches:
          type: array
          items:
            $ref: "#/components/schemas/SimilarityMatch"
        sectionHashes:
          type: object
          additionalProperties:
            type: string
          description: SHA-256 hashes of each report section for granular similarity
        sectionMatches:
          type: array
          items:
            $ref: "#/components/schemas/SectionMatchItem"
          description: Sections that match existing reports
        redactedText:
          type: ["string", "null"]
          description: Auto-redacted version of the report (PII/secrets removed)
        redactionSummary:
          $ref: "#/components/schemas/RedactionSummary"
        feedback:
          type: array
          items:
            type: string
          description: Heuristic feedback strings — specific issues flagged by the rule-based engine
        llmSlopScore:
          type: ["integer", "null"]
          description: LLM analysis slop score (0–100), weighted from 5 dimensions (specificity, originality, voice, coherence, hallucination). Null when LLM is unavailable. Contributes to the fused slopScore via multi-axis Noisy-OR combination.
        llmFeedback:
          type: ["array", "null"]
          items:
            type: string
          description: Semantic observations from the LLM scorer. Null when LLM analysis is unavailable.
        llmBreakdown:
          type: ["object", "null"]
          properties:
            claimSpecificity:
              type: integer
              description: Claim specificity score (0-25)
            evidenceQuality:
              type: integer
              description: Evidence quality score (0-25)
            internalConsistency:
              type: integer
              description: Internal consistency score (0-25)
            hallucinationSignals:
              type: integer
              description: Hallucination signals score (0-25, higher = fewer hallucinations)
            validityScore:
              type: integer
              description: Overall validity score (0-100)
            redFlags:
              type: array
              items:
                type: string
            greenFlags:
              type: array
              items:
                type: string
            verdict:
              type: string
              description: "LIKELY_VALID | UNCERTAIN | LIKELY_FABRICATED"
            specificity:
              type: integer
              description: Legacy V1 field (deprecated)
            originality:
              type: integer
              description: Legacy V1 field (deprecated)
            voice:
              type: integer
              description: Legacy V1 field (deprecated)
            coherence:
              type: integer
              description: Legacy V1 field (deprecated)
            hallucination:
              type: integer
              description: Legacy V1 field (deprecated)
          description: Per-criterion LLM validity scores. Null when LLM analysis is unavailable. May contain legacy V1 fields for older records.
        humanIndicators:
          type: array
          items:
            $ref: "#/components/schemas/HumanIndicator"
          description: Detected human-writing signals that reduced the slop score
        authenticityScore:
          type: integer
          description: AI-authorship likelihood score 0-100 (higher = more likely AI-generated). Measures writing style, spectral patterns, template usage.
        validityScore:
          type: integer
          description: Technical substance/validity score 0-100 (higher = stronger evidence of a real vulnerability). Measures evidence quality, claim specificity, internal consistency.
        quadrant:
          type: string
          enum: ["AI_SLOP", "AI_ASSISTED", "WEAK_HUMAN", "STRONG_HUMAN"]
          description: Two-axis classification quadrant. AI_SLOP=high auth/low valid, AI_ASSISTED=high auth/high valid, WEAK_HUMAN=low auth/low valid, STRONG_HUMAN=low auth/high valid.
        archetype:
          type: string
          enum: ["AUTO_CLOSE", "PRIORITIZE_REVIEW", "REQUEST_DETAILS", "ACCEPT"]
          description: Recommended triage action derived from quadrant classification.
        analysisMode:
          type: string
          enum: ["heuristic_only", "llm_enhanced"]
          description: Whether the analysis ran in heuristic-only mode or with LLM enhancement.
        confidenceNote:
          type: ["string", "null"]
          description: Human-readable note explaining confidence adjustments (e.g. LLM-free mode reduces confidence by 15%).
        configNotices:
          type: array
          items:
            type: object
            properties:
              setting:
                type: string
              severity:
                type: string
                enum: ["info", "warning", "critical"]
              title:
                type: string
              summary:
                type: string
              impact:
                type: object
                additionalProperties:
                  type: string
              recommendation:
                type: string
          description: Configuration impact notices explaining how current settings affect analysis accuracy, latency, and privacy.
        diagnostics:
          type: ["object", "null"]
          properties:
            inputStats:
              type: object
              properties:
                charCount:
                  type: integer
                lineCount:
                  type: integer
                wordCount:
                  type: integer
                maxLineLength:
                  type: integer
                containsPlaceholders:
                  type: boolean
            stages:
              type: object
              additionalProperties:
                type: object
                properties:
                  status:
                    type: string
                  durationMs:
                    type: integer
                  error:
                    type: string
            parseWarnings:
              type: array
              items:
                type: object
                properties:
                  type:
                    type: string
                  detail:
                    type: string
            totalDurationMs:
              type: integer
            crashInfo:
              type: ["object", "null"]
              properties:
                message:
                  type: string
                stage:
                  type: string
                inputLength:
                  type: integer
          description: Pipeline diagnostics showing which analysis stages ran, their durations, and any warnings. crashInfo is non-null when the pipeline ran in degraded mode.
        adjustedScore:
          type: ["integer", "null"]
          description: Client-adjusted slop score based on sensitivity profile. Null when no adjustment applied (balanced preset).
        sensitivityProfile:
          type: ["string", "null"]
          enum: ["lenient", "balanced", "strict", null]
          description: Active sensitivity preset used for score adjustment. Null when default (balanced).
        llmEnhanced:
          type: boolean
          description: True when LLM analysis contributed to the final slopScore. False means the score is purely heuristic.
        llmFailed:
          type: boolean
          description: True when LLM analysis was attempted but failed (timeout, parse error, API error). False when LLM succeeded or was not attempted.
        llmUsed:
          type: boolean
          description: Whether LLM analysis was attempted (not skipped by user). When false, the user explicitly opted out of AI analysis.
        redactionApplied:
          type: boolean
          description: Whether PII auto-redaction was applied. When false, the user explicitly disabled redaction.
        verification:
          oneOf:
            - $ref: "#/components/schemas/Verification"
            - type: "null"
          description: Active content verification results (GitHub, NVD, PoC checks). Null when verification was not performed.
        triageRecommendation:
          oneOf:
            - $ref: "#/components/schemas/TriageRecommendation"
            - type: "null"
          description: Automated triage action recommendation with challenge questions and behavioral signals. Null when not computed.
        triageAssistant:
          oneOf:
            - $ref: "#/components/schemas/TriageAssistant"
            - type: "null"
          description: AI triage assistant with reproduction guidance, gap analysis, don't-miss warnings, and reporter feedback. Null when not computed.
        claims:
          oneOf:
            - $ref: "#/components/schemas/LLMClaims"
            - type: "null"
          description: Extracted claims from the report (project, files, functions, PoC presence, etc.). Null when LLM analysis was not performed.
        substance:
          oneOf:
            - $ref: "#/components/schemas/LLMSubstanceScores"
            - type: "null"
          description: Substance-based scoring (PoC validity, claim specificity, domain coherence). Null when LLM analysis was not performed.
        vulnrap:
          oneOf:
            - $ref: "#/components/schemas/VulnrapComposite"
            - type: "null"
          description: Sprint 9 multi-engine consensus score (3 engines). Null when not computed (e.g., legacy reports).
        avriFamily:
          type: ["string", "null"]
          description: Cached AVRI rubric family id for this report (e.g. INJECTION, MEMORY_CORRUPTION). Sourced from the persisted reports.avri_family column; null for legacy rows submitted before the family was persisted.
        engineVersions:
          oneOf:
            - $ref: "#/components/schemas/EngineVersions"
            - type: "null"
          description: Semver pin of every scoring engine that produced this report. Null for legacy rows scored before the engine_versions column shipped (Task #624).
        fileName:
          type: ["string", "null"]
        fileSize:
          type: integer
        createdAt:
          type: string
          format: date-time
        promptInjectionDetected:
          type: boolean
          description: Whether a prompt-injection attempt was detected in the submitted report text. False when no injection patterns matched.
        promptInjectionLabels:
          type: array
          items:
            type: string
          description: Injection pattern labels that fired (e.g. "ignore_previous_instructions", "role_flip_act_as"). Empty when promptInjectionDetected is false.
      required:
        - id
        - contentHash
        - contentMode
        - slopScore
        - slopTier
        - qualityScore
        - confidence
        - breakdown
        - evidence
        - similarityMatches
        - sectionHashes
        - sectionMatches
        - redactionSummary
        - feedback
        - llmEnhanced
        - fileSize
        - createdAt

    EngineVersions:
      type: object
      description: Semver pins for each scoring engine that produced a report. Bumped when an engine's behavior changes so reviewers can attribute score deltas to the right release.
      properties:
        linguistic:
          type: string
          description: Semver of the linguistic-slop scorer.
        substance:
          type: string
          description: Semver of the substance scorer.
        cwe:
          type: string
          description: Semver of the CWE classifier.
        avri:
          type: string
          description: Semver of the AVRI rubric engine.
        fusion:
          type: string
          description: Semver of the composite fusion algorithm.
      required:
        - linguistic
        - substance
        - cwe
        - avri
        - fusion

    VulnrapComposite:
      type: object
      description: VulnRap multi-engine consensus score with per-engine breakdowns.
      properties:
        compositeScore:
          type: integer
          description: Composite score 0-100, where higher = stronger evidence of a real, reproducible vulnerability.
        label:
          type: string
          enum:
            [
              "LIKELY INVALID",
              "HIGH RISK",
              "NEEDS REVIEW",
              "REASONABLE",
              "PROMISING",
              "STRONG",
            ]
        engines:
          type: array
          items:
            $ref: "#/components/schemas/VulnrapEngineResult"
        compositeBreakdown:
          type: object
          properties:
            weightedSum:
              type: number
            totalWeight:
              type: number
            beforeOverride:
              type: integer
            afterOverride:
              type: integer
        overridesApplied:
          type: array
          items:
            type: string
          description: Composite-level override rules that fired (e.g. CONVERGENT_NEGATIVE, CWE_TYPE_SWAP).
        warnings:
          type: array
          items:
            type: string
        engineCount:
          type: integer
        reconstructed:
          type: boolean
          description: True when the composite was rebuilt by backfill-vulnrap from cached v3.5.0 signals (legacy reports stored without raw text). Reviewers should treat the score as approximate — CWE coherence is neutralized, no perplexity is available, and per-engine scores derive from cached slop / validity / quality / evidence values rather than a fresh pipeline run.
        rescoreHistory:
          type: array
          description: Task #389 — chronological audit trail (oldest first) of times the bulk vulnrap backfill rewrote this report's composite. Empty when the row has only ever been scored by a normal recheck or first-time analysis. Lets reviewers tell from the row itself that the composite was changed by a `--rescore` run (vs. a real recheck) and what the prior value was.
          items:
            type: object
            required:
              - source
              - mode
              - rescoredAt
              - priorCompositeScore
              - newCompositeScore
              - newCorrelationId
            properties:
              source:
                type: string
                enum: ["backfill-rescore"]
                description: Always `backfill-rescore` for now; reserved for future audit sources (e.g. manual reviewer overrides).
              mode:
                type: string
                enum: ["engine", "reconstruction"]
                description: Which rescore branch produced this entry. `engine` means the live pipeline re-ran on stored text; `reconstruction` means the composite was rebuilt from cached v3.5.0 signals.
              rescoredAt:
                type: string
                format: date-time
                description: ISO timestamp of the moment the rescore wrote this row.
              priorCompositeScore:
                type: integer
                description: Composite score the row carried before this rescore.
              priorCompositeLabel:
                type: string
                nullable: true
                description: Composite label the row carried before this rescore (null for very old rows where the label column was unset).
              priorCorrelationId:
                type: string
                nullable: true
                description: Correlation id the row carried before this rescore (null for legacy rows analyzed before the column existed).
              newCompositeScore:
                type: integer
                description: Composite score this rescore wrote onto the row.
              newCompositeLabel:
                type: string
                description: Composite label this rescore wrote onto the row.
              newCorrelationId:
                type: string
                description: Correlation id this rescore wrote onto the row (joinable to the analysis_traces table for engine-mode rescores; `recon-…` for reconstruction-mode rescores).
      required:
        - compositeScore
        - label
        - engines
        - overridesApplied

    VulnrapEngineResult:
      type: object
      properties:
        engine:
          type: string
          description: Human-readable engine name.
        score:
          type: integer
        verdict:
          type: string
          enum: ["GREEN", "YELLOW", "RED", "GREY"]
        confidence:
          type: string
          enum: ["HIGH", "MEDIUM", "LOW"]
        triggeredIndicators:
          type: array
          items:
            type: object
            properties:
              signal:
                type: string
              value:
                description: Indicator value; may be string, number, or boolean.
              threshold:
                type: number
              strength:
                type: string
                enum: ["HIGH", "MEDIUM", "LOW"]
              explanation:
                type: string
        signalBreakdown:
          type: object
          additionalProperties: true
        note:
          type: string
      required:
        - engine
        - score
        - verdict
        - confidence

    VerificationBadge:
      type: object
      properties:
        id:
          type: integer
        reportCode:
          type: string
          description: Human-readable report code (e.g. VR-000B)
        slopScore:
          type: integer
        slopTier:
          type: string
        similarityMatchCount:
          type: integer
        sectionMatchCount:
          type: integer
        contentHash:
          type: string
        verifyUrl:
          type: string
          description: Public URL to verify this report
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - reportCode
        - slopScore
        - slopTier
        - similarityMatchCount
        - sectionMatchCount
        - contentHash
        - verifyUrl
        - createdAt

    CheckResult:
      type: object
      properties:
        slopScore:
          type: integer
        slopTier:
          type: string
        qualityScore:
          type: integer
          description: Report quality/completeness score 0-100
        confidence:
          type: number
          format: float
          description: Confidence in the slopScore (0.0-1.0)
        breakdown:
          $ref: "#/components/schemas/ScoreBreakdown"
        evidence:
          type: array
          items:
            $ref: "#/components/schemas/EvidenceItem"
        similarityMatches:
          type: array
          items:
            $ref: "#/components/schemas/SimilarityMatch"
        sectionHashes:
          type: object
          additionalProperties:
            type: string
        sectionMatches:
          type: array
          items:
            $ref: "#/components/schemas/SectionMatchItem"
        redactionSummary:
          $ref: "#/components/schemas/RedactionSummary"
        feedback:
          type: array
          items:
            type: string
          description: Heuristic feedback strings
        llmSlopScore:
          type: ["integer", "null"]
        llmFeedback:
          type: ["array", "null"]
          items:
            type: string
        llmBreakdown:
          type: ["object", "null"]
          properties:
            claimSpecificity:
              type: integer
            evidenceQuality:
              type: integer
            internalConsistency:
              type: integer
            hallucinationSignals:
              type: integer
            validityScore:
              type: integer
            redFlags:
              type: array
              items:
                type: string
            greenFlags:
              type: array
              items:
                type: string
            verdict:
              type: string
            specificity:
              type: integer
            originality:
              type: integer
            voice:
              type: integer
            coherence:
              type: integer
            hallucination:
              type: integer
        humanIndicators:
          type: array
          items:
            $ref: "#/components/schemas/HumanIndicator"
          description: Detected human-writing signals that reduced the slop score
        authenticityScore:
          type: integer
          description: AI-authorship likelihood score 0-100.
        validityScore:
          type: integer
          description: Technical substance/validity score 0-100.
        quadrant:
          type: string
          enum: ["AI_SLOP", "AI_ASSISTED", "WEAK_HUMAN", "STRONG_HUMAN"]
          description: Two-axis classification quadrant.
        archetype:
          type: string
          enum: ["AUTO_CLOSE", "PRIORITIZE_REVIEW", "REQUEST_DETAILS", "ACCEPT"]
          description: Recommended triage action derived from quadrant.
        analysisMode:
          type: string
          enum: ["heuristic_only", "llm_enhanced"]
          description: Whether the analysis ran in heuristic-only mode or with LLM enhancement.
        confidenceNote:
          type: ["string", "null"]
          description: Human-readable note explaining confidence adjustments.
        configNotices:
          type: array
          items:
            type: object
            properties:
              setting:
                type: string
              severity:
                type: string
                enum: ["info", "warning", "critical"]
              title:
                type: string
              summary:
                type: string
              impact:
                type: object
                additionalProperties:
                  type: string
              recommendation:
                type: string
          description: Configuration impact notices explaining how current settings affect analysis.
        diagnostics:
          type: ["object", "null"]
          properties:
            inputStats:
              type: object
              properties:
                charCount:
                  type: integer
                lineCount:
                  type: integer
                wordCount:
                  type: integer
                maxLineLength:
                  type: integer
                containsPlaceholders:
                  type: boolean
            stages:
              type: object
              additionalProperties:
                type: object
                properties:
                  status:
                    type: string
                  durationMs:
                    type: integer
                  error:
                    type: string
            parseWarnings:
              type: array
              items:
                type: object
                properties:
                  type:
                    type: string
                  detail:
                    type: string
            totalDurationMs:
              type: integer
            crashInfo:
              type: ["object", "null"]
              properties:
                message:
                  type: string
                stage:
                  type: string
                inputLength:
                  type: integer
          description: Pipeline diagnostics showing which analysis stages ran, their durations, and any warnings. crashInfo is non-null when the pipeline ran in degraded mode.
        adjustedScore:
          type: ["integer", "null"]
          description: Client-adjusted slop score based on sensitivity profile. Null when no adjustment applied.
        sensitivityProfile:
          type: ["string", "null"]
          enum: ["lenient", "balanced", "strict", null]
          description: Active sensitivity preset used for score adjustment. Null when default.
        llmEnhanced:
          type: boolean
        llmFailed:
          type: boolean
          description: True when LLM analysis was attempted but failed.
        llmUsed:
          type: boolean
          description: Whether LLM analysis was attempted (not skipped by user).
        redactionApplied:
          type: boolean
          description: Whether PII auto-redaction was applied.
        verification:
          oneOf:
            - $ref: "#/components/schemas/Verification"
            - type: "null"
          description: Active content verification results. Null when verification was not performed.
        triageRecommendation:
          oneOf:
            - $ref: "#/components/schemas/TriageRecommendation"
            - type: "null"
          description: Automated triage action recommendation with challenge questions and behavioral signals. Null when not computed.
        triageAssistant:
          oneOf:
            - $ref: "#/components/schemas/TriageAssistant"
            - type: "null"
          description: AI triage assistant with reproduction guidance, gap analysis, don't-miss warnings, and reporter feedback. Null when not computed.
        claims:
          oneOf:
            - $ref: "#/components/schemas/LLMClaims"
            - type: "null"
          description: Extracted claims from the report. Null when LLM analysis was not performed.
        substance:
          oneOf:
            - $ref: "#/components/schemas/LLMSubstanceScores"
            - type: "null"
          description: Substance-based scoring. Null when LLM analysis was not performed.
        previouslySubmitted:
          type: boolean
          description: Whether this exact report was found in the database
        existingReportId:
          type: ["integer", "null"]
          description: If previously submitted, the existing report ID
      required:
        - slopScore
        - slopTier
        - qualityScore
        - confidence
        - breakdown
        - evidence
        - similarityMatches
        - sectionHashes
        - sectionMatches
        - redactionSummary
        - feedback
        - llmEnhanced
        - previouslySubmitted

    DryRunBatchBody:
      type: object
      description: |
        Request body for `POST /reports/check/dry-run-batch`. Each item is
        a single report body (Markdown, plain text, or anything you'd
        normally drop into the `rawText` field of `POST /reports/check`).
      properties:
        reports:
          type: array
          minItems: 1
          maxItems: 25
          description: Up to 25 report bodies to score in one call.
          items:
            type: object
            properties:
              id:
                type: string
                maxLength: 128
                description: |
                  Optional caller-supplied identifier (e.g. the HackerOne
                  report id) echoed back on the matching response item so
                  callers can join results to their inbox without relying
                  on array order.
              rawText:
                type: string
                minLength: 1
                description: The full report body to score (Markdown allowed).
            required:
              - rawText
      required:
        - reports

    DryRunBatchResultItem:
      type: object
      properties:
        id:
          type: ["string", "null"]
          description: Echo of the caller-supplied id (null when none was provided).
        index:
          type: integer
          description: Zero-based position of this item in the request `reports` array.
        compositeScore:
          type: ["integer", "null"]
          description: |
            Composite score 0..100, higher = better. `null` when the
            scoring pipeline could not produce a composite (e.g. the
            new-composite engine layer is disabled server-side).
        action:
          type: string
          enum:
            [
              AUTO_CLOSE,
              MANUAL_REVIEW,
              CHALLENGE_REPORTER,
              PRIORITIZE,
              STANDARD_TRIAGE,
            ]
          description: Recommended triage action from the same matrix used by `POST /reports/check`.
        reason:
          type: string
          description: One-line explanation the matrix used for this action.
        goldHitCount:
          type: integer
          description: |
            Number of AVRI family-specific gold signals (sanitizer trace,
            injection payload, etc.) detected by the Technical Substance
            Analyzer. 0 when no gold signals matched.
        wouldAutoClose:
          type: boolean
          description: |
            True iff this item satisfies the auto-close safety gates
            documented in the platform-specific recipes:
            `action == AUTO_CLOSE` AND `goldHitCount == 0`. Use this to
            preview what a "flip auto-close on" rollout would have done
            against historical inbox traffic.
        error:
          type: ["string", "null"]
          description: |
            Per-item error message when scoring this item failed (the
            request as a whole still returns 200 so partial failures
            don't poison the rest of the batch). `null` on success.
      required:
        - id
        - index
        - compositeScore
        - action
        - reason
        - goldHitCount
        - wouldAutoClose
        - error

    DryRunBatchResult:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DryRunBatchResultItem"
        summary:
          type: object
          properties:
            total:
              type: integer
              description: Total items in the batch (matches `reports.length` in the request).
            scored:
              type: integer
              description: Items that scored without error.
            wouldAutoCloseCount:
              type: integer
              description: How many items satisfied the auto-close safety gates.
          required:
            - total
            - scored
            - wouldAutoCloseCount
      required:
        - items
        - summary

    HashLookupResult:
      type: object
      properties:
        found:
          type: boolean
        reportId:
          type: ["integer", "null"]
        slopScore:
          type: ["integer", "null"]
        slopTier:
          type: ["string", "null"]
        matchCount:
          type: integer
          description: Number of similar reports found
        firstSeen:
          type: ["string", "null"]
          format: date-time
      required:
        - found
        - matchCount

    PlatformStats:
      type: object
      properties:
        totalReports:
          type: integer
        duplicatesDetected:
          type: integer
        avgSlopScore:
          type: number
        reportsByMode:
          type: object
          properties:
            full:
              type: integer
            similarity_only:
              type: integer
          required:
            - full
            - similarity_only
        reportsToday:
          type: integer
        reportsThisWeek:
          type: integer
      required:
        - totalReports
        - duplicatesDetected
        - avgSlopScore
        - reportsByMode
        - reportsToday
        - reportsThisWeek

    RecentActivity:
      type: object
      properties:
        recentReports:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              slopScore:
                type: integer
              slopTier:
                type: string
              matchCount:
                type: integer
              createdAt:
                type: string
                format: date-time
            required:
              - id
              - slopScore
              - slopTier
              - matchCount
              - createdAt
      required:
        - recentReports

    ReportFeed:
      type: object
      properties:
        reports:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              reportCode:
                type: string
              slopScore:
                type: integer
              slopTier:
                type: string
              matchCount:
                type: integer
              contentMode:
                type: string
                enum: [full, similarity_only]
              createdAt:
                type: string
                format: date-time
              avriFamily:
                type: ["string", "null"]
                description: Cached AVRI rubric family id for this report (e.g. INJECTION, MEMORY_CORRUPTION). Null for legacy rows submitted before the family was persisted.
              fakeRawHttp:
                type: boolean
                default: false
                description: |
                  True when AVRI Engine 2 flagged the report's raw HTTP request bytes as fabricated
                  (signalBreakdown.avri.rawHttp.isFake = true). Lets the reports feed display a
                  FAKE_RAW_HTTP indicator on the row so reviewers triaging the queue can spot
                  fabricated-raw-HTTP REQUEST_SMUGGLING reports without opening each one.
              strippedCrashTrace:
                type: boolean
                default: false
                description: |
                  True when AVRI Engine 2 flagged the report's crash/race trace as stripped
                  (signalBreakdown.avri.crashTrace.isStripped = true). Paired with fakeRawHttp so
                  the two block types stay symmetric in the feed UI.
              inferredCwe:
                type: ["string", "null"]
                description: |
                  Soft-citation inferred CWE (e.g. "CWE-79") for reports where the engine
                  recognised a vulnerability class name but no explicit CWE token was cited.
                  Sourced from signalBreakdown.avri.softCitation.inferredCwe (preferred) or
                  signalBreakdown.softCitation.inferredCwe (legacy) on the CWE Coherence
                  Checker engine — same field the triage report panel already consumes.
                  Null when no soft citation fired for the report.
              inferredCweName:
                type: ["string", "null"]
                description: |
                  Friendly name of the soft-citation match (e.g. "XSS", "Open Redirect")
                  paired with inferredCwe. Null when inferredCwe is null. Used as a tooltip
                  on the row badge so reviewers can see the matched class without opening
                  the diagnostics panel.
              agentFingerprintLabel:
                type: ["string", "null"]
                description: |
                  Human-readable label of the most-likely AI agent that authored this
                  report (e.g. "Claude", "GPT-4 / ChatGPT"). Null when the detector
                  verdict is "unknown" — i.e. too little stylistic evidence to classify.
                  Sourced from the same heuristic fingerprint detector used on the
                  diagnostics panel (detectAgentFingerprint), computed inline at feed
                  time over the persisted report body.
              agentFingerprintConfidence:
                type: ["number", "null"]
                description: |
                  Confidence (0–1) in the agentFingerprintLabel verdict. Null when
                  agentFingerprintLabel is null. Displayed as a percentage on the feed
                  row pill (e.g. "64%"). Capped at 0.95 by the detector.
            required:
              - id
              - reportCode
              - slopScore
              - slopTier
              - matchCount
              - contentMode
              - createdAt
              - fakeRawHttp
              - strippedCrashTrace
        total:
          type: integer
        hasMore:
          type: boolean
        summary:
          type: object
          properties:
            totalPublic:
              type: integer
            avgScore:
              type: number
            tierCounts:
              type: object
              additionalProperties:
                type: integer
            familyCounts:
              type: object
              additionalProperties:
                type: integer
              description: Per-AVRI-family counts of public reports (mirrors tierCounts). Keys are AVRI family ids (e.g. INJECTION, MEMORY_CORRUPTION); rows with null avri_family are bucketed under "FLAT".
          required:
            - totalPublic
            - avgScore
            - tierCounts
            - familyCounts
      required:
        - reports
        - total
        - hasMore
        - summary

    FeedEngineVersions:
      type: object
      properties:
        versions:
          type: array
          items:
            type: string
          description: Distinct fusion engine version strings present in the public feed, sorted in descending semver order.
        linguisticVersions:
          type: array
          items:
            type: string
          description: Distinct linguistic engine version strings present in the public feed, sorted in descending semver order.
        substanceVersions:
          type: array
          items:
            type: string
          description: Distinct substance engine version strings present in the public feed, sorted in descending semver order.
        cweVersions:
          type: array
          items:
            type: string
          description: Distinct CWE engine version strings present in the public feed, sorted in descending semver order.
        avriVersions:
          type: array
          items:
            type: string
          description: Distinct AVRI engine version strings present in the public feed, sorted in descending semver order.
      required:
        - versions
        - linguisticVersions
        - substanceVersions
        - cweVersions
        - avriVersions

    CohortBaseline:
      type: object
      description: |
        Last-7d VulnRap composite-score distribution + median for a cohort,
        plus the optional CWE family used to scope the cohort. The 10
        histogram bins partition the score range 0-100 into equal-width
        [min, max) intervals (the last bucket is inclusive of 100). Composite
        score is the metric shown in the big number on the results page
        header, so the percentile rank derived from these bins applies to
        that displayed score.
      properties:
        cwe:
          type: ["string", "null"]
          description: |
            Cohort scope. Null when the cohort is the full platform-wide
            distribution; otherwise the AVRI rubric family id (e.g. INJECTION).
            When the caller passed an unknown value this is null and the
            platform cohort is returned instead.
        windowDays:
          type: integer
          description: Width of the rolling window the baseline was computed over (always 7 for v1).
        totalReports:
          type: integer
          description: Number of reports in the cohort (sum of bucket counts).
        median:
          type: ["integer", "null"]
          description: Median composite score for the cohort. Null when totalReports is 0.
        percentile:
          type: ["number", "null"]
          description: |
            Exact percentile rank for the queried `score` against the
            underlying cohort rows (not the bucketed histogram), computed
            with the mid-rank convention so ties contribute half their
            count. Present only when the request supplied a `score` query
            parameter and the cohort is non-empty; null otherwise. The
            value is a 0..100 number rounded to one decimal place — the
            UI prefers this over the bucket-derived percentile when
            present.
        queriedScore:
          type: ["number", "null"]
          description: |
            Echo of the `score` query parameter the percentile was computed
            against, clamped to 0..100. Null when no `score` was supplied.
        bins:
          type: array
          description: 10-bucket histogram of composite scores in the cohort.
          items:
            type: object
            properties:
              min:
                type: integer
                description: Inclusive lower bound of the bucket (0-100).
              max:
                type: integer
                description: Exclusive upper bound of the bucket (inclusive on the final bucket).
              count:
                type: integer
                description: Number of reports whose composite score falls in this bucket.
            required:
              - min
              - max
              - count
        engineMedians:
          type: object
          nullable: true
          description: |
            Median per-axis sub-scores across the same cohort, used by the
            per-engine radar overlay on the results page. Each value is
            0..100 and uses the same orientation as the on-page radar
            (engine1 = AI Authorship raw score; the UI inverts it to plot
            "humanness"). Null when totalReports is 0 or when the request
            specified metric=slop (engine medians are only meaningful for
            the composite cohort).
          properties:
            engine1:
              type: integer
              nullable: true
              description: Median raw score for Engine 1 (AI Authorship Detector). Null if no rows in the cohort had this engine.
            engine2:
              type: integer
              nullable: true
              description: Median score for Engine 2 (Technical Substance Analyzer).
            engine3:
              type: integer
              nullable: true
              description: Median score for Engine 3 (CWE Coherence Checker).
            avri:
              type: integer
              nullable: true
              description: Median raw AVRI sub-score (from Engine 2's signalBreakdown.avri.rawAvriScore).
            quality:
              type: integer
              nullable: true
              description: Median structural quality_score for the cohort.
          required:
            - engine1
            - engine2
            - engine3
            - avri
            - quality
      required:
        - cwe
        - windowDays
        - totalReports
        - median
        - bins
        - engineMedians

    CorpusStats:
      type: object
      description: |
        Already-aggregated corpus statistics for the public stats page.
        Contains no individual report content — only counts and totals over
        the full scoring corpus.
      properties:
        totalReports:
          type: integer
          description: Total number of reports scored to date.
        generatedAt:
          type: string
          format: date-time
          description: Timestamp when these aggregates were computed.
        tierBreakdown:
          type: array
          description: Count of reports per slop tier label.
          items:
            type: object
            properties:
              tier:
                type: string
              count:
                type: integer
            required:
              - tier
              - count
        topSignals:
          type: array
          description: Top 10 most-fired evidence signal types across the corpus.
          items:
            type: object
            properties:
              signal:
                type: string
              count:
                type: integer
            required:
              - signal
              - count
        topCweFamilies:
          type: array
          description: |
            Top 10 AVRI rubric CWE families across reports (e.g. INJECTION,
            MEMORY_CORRUPTION). Reports with no cached family are omitted.
          items:
            type: object
            properties:
              family:
                type: string
              count:
                type: integer
            required:
              - family
              - count
        volumeTimeSeries:
          type: array
          description: Daily report volume for the last 90 days (oldest first).
          items:
            type: object
            properties:
              date:
                type: string
              count:
                type: integer
            required:
              - date
              - count
      required:
        - totalReports
        - generatedAt
        - tierBreakdown
        - topSignals
        - topCweFamilies
        - volumeTimeSeries

    CweCatalogEntry:
      type: object
      description: Single CWE entry in the public catalog (Task #663).
      properties:
        cweId:
          type: string
          description: Canonical CWE identifier (e.g. "CWE-79").
        name:
          type: string
          description: Friendly name (e.g. "Cross-Site Scripting (XSS)").
        family:
          type: string
          description: AVRI rubric family id this CWE belongs to (INJECTION, MEMORY_CORRUPTION, ...). FLAT for unclassified.
        familyName:
          type: string
          description: Human-readable family name (matches the AVRI rubric displayName).
        aliases:
          type: array
          description: Alternate phrasings the engine recognizes for this CWE.
          items:
            type: string
        expectedTerms:
          type: array
          description: Vocabulary the engine expects to see in a high-quality report for this CWE.
          items:
            type: string
        mitreUrl:
          type: string
          description: Link to the canonical MITRE CWE definition page.
        highQualityReport:
          type: string
          description: Short narrative — what makes a report for this CWE score well, derived from the family rubric.
        reproductionExpectation:
          type: string
          description: Reproduction expectation inherited from the AVRI family rubric.
        rejectionRate:
          type: number
          description: Historical rejection-rate multiplier from the fingerprint library (HIGH_REJECTION_CWES override when present).
        reportCount:
          type: integer
          description: Number of corpus reports where the engine inferred this CWE via soft-citation.
        avgCompositeScore:
          type: number
          nullable: true
          description: Average vulnrap_composite_score across reports where this CWE was inferred. Null when there are no such reports.
        reportsLink:
          type: string
          description: Deep link into the public reports feed pre-filtered to this CWE's family.
      required:
        - cweId
        - name
        - family
        - familyName
        - aliases
        - expectedTerms
        - mitreUrl
        - highQualityReport
        - reproductionExpectation
        - rejectionRate
        - reportCount
        - avgCompositeScore
        - reportsLink

    CweCatalogFamily:
      type: object
      description: Group of CWEs that belong to a single AVRI rubric family.
      properties:
        family:
          type: string
        familyName:
          type: string
        reproductionExpectation:
          type: string
        entries:
          type: array
          items:
            $ref: "#/components/schemas/CweCatalogEntry"
      required:
        - family
        - familyName
        - reproductionExpectation
        - entries

    CweCatalogResponse:
      type: object
      description: Public CWE catalog response (Task #663).
      properties:
        generatedAt:
          type: string
          format: date-time
        totalCwes:
          type: integer
          description: Total number of CWE fingerprints the engine has shipped.
        families:
          type: array
          items:
            $ref: "#/components/schemas/CweCatalogFamily"
      required:
        - generatedAt
        - totalCwes
        - families

    SlopDistribution:
      type: object
      properties:
        buckets:
          type: array
          items:
            type: object
            properties:
              label:
                type: string
              min:
                type: integer
              max:
                type: integer
              count:
                type: integer
            required:
              - label
              - min
              - max
              - count
        totalReports:
          type: integer
      required:
        - buckets
        - totalReports

    ComparisonReportDetail:
      type: object
      properties:
        id:
          type: integer
        reportCode:
          type: string
        snippet:
          type: ["string", "null"]
          description: First 2000 chars of redacted text (null if similarity_only mode)
        slopScore:
          type: integer
        slopTier:
          type: string
        contentMode:
          type: string
          enum: [full, similarity_only]
        sectionHashes:
          type: object
          additionalProperties:
            type: string
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - reportCode
        - slopScore
        - slopTier
        - contentMode
        - createdAt

    SectionComparisonItem:
      type: object
      properties:
        sectionTitle:
          type: string
        status:
          type: string
          enum: [identical, different, unique]
          description: Whether this section is identical, different, or only in one report
        sourceHash:
          type: ["string", "null"]
        matchedHash:
          type: ["string", "null"]
      required:
        - sectionTitle
        - status

    ReportComparison:
      type: object
      properties:
        sourceReport:
          $ref: "#/components/schemas/ComparisonReportDetail"
        matchedReport:
          $ref: "#/components/schemas/ComparisonReportDetail"
        similarity:
          type: integer
          description: Similarity percentage between the two reports
        matchType:
          type: string
        sectionComparison:
          type: array
          items:
            $ref: "#/components/schemas/SectionComparisonItem"
          description: Section-by-section comparison showing identical, different, and unique sections
        identicalSections:
          type: integer
          description: Number of sections with identical content
        totalSections:
          type: integer
          description: Total unique sections across both reports
      required:
        - sourceReport
        - matchedReport
        - similarity
        - matchType
        - sectionComparison
        - identicalSections
        - totalSections

    VerificationCheck:
      type: object
      properties:
        type:
          type: string
          description: Check type identifier (e.g. github_file_verified, cve_not_in_nvd, poc_placeholder_textbook)
        target:
          type: string
          description: The reference being verified (e.g. repo:filepath, CVE-ID)
        result:
          type: string
          enum: [verified, not_found, warning, error, skipped, info]
        detail:
          type: string
          description: Human-readable explanation of the check result
        weight:
          type: integer
          description: Score weight (negative = human signal, positive = slop signal)
        source:
          type: string
          enum: [referenced_in_report, search_fallback]
          description: Whether the check was performed against a repo/resource explicitly cited in the report (referenced_in_report) or one we guessed via keyword fallback (search_fallback). Absent when not applicable (e.g. CVE/PoC checks).
      required:
        - type
        - target
        - result
        - detail
        - weight

    VerificationSummary:
      type: object
      properties:
        verified:
          type: integer
        notFound:
          type: integer
        warnings:
          type: integer
        errors:
          type: integer
      required:
        - verified
        - notFound
        - warnings
        - errors

    DetectedProject:
      type: object
      properties:
        name:
          type: string
        repoSlug:
          type: string
        source:
          type: string
          description: How the project was detected (github_url, gitlab_url, known_project)
      required:
        - name
        - repoSlug
        - source

    Verification:
      type: object
      properties:
        checks:
          type: array
          items:
            $ref: "#/components/schemas/VerificationCheck"
        summary:
          $ref: "#/components/schemas/VerificationSummary"
        triageNotes:
          type: array
          items:
            type: string
          description: Actionable notes for PSIRT triage based on verification results
        score:
          type: integer
          description: Verification axis score (0-100, 50=neutral, above=slop signals, below=human signals)
        detectedProjects:
          type: array
          items:
            $ref: "#/components/schemas/DetectedProject"
        mode:
          type: string
          enum:
            - SOURCE_CODE
            - ENDPOINT
            - MANUAL_ONLY
            - GENERIC
          description: |
            Active-verification routing decision. SOURCE_CODE probes detected
            GitHub repos for cited file paths/symbols; ENDPOINT runs PoC
            plausibility checks; MANUAL_ONLY short-circuits automated probes
            (race conditions, request smuggling) and only verifies CVE
            existence; GENERIC runs both source-code and endpoint probes.
        familyName:
          type: string
          description: Human-readable AVRI family name that drove the verification mode (e.g. "Web client (XSS / CSRF / clickjacking / open redirect)").
      required:
        - checks
        - summary
        - triageNotes
        - score
        - detectedProjects

    ScoreBreakdown:
      type: object
      properties:
        linguistic:
          type: integer
          description: Linguistic AI fingerprinting score (0-100)
        factual:
          type: integer
          description: Factual verification score (0-100)
        template:
          type: integer
          description: Template/mass-submission pattern score (0-100)
        llm:
          type: ["integer", "null"]
          description: LLM analysis score (0-100), null if LLM unavailable
        verification:
          type: ["integer", "null"]
          description: Active content verification score (0-100, 50=neutral). Null when no verifiable references found.
        quality:
          type: integer
          description: Report quality score (0-100)
        substanceScore:
          type: ["integer", "null"]
          description: LLM substance score (0-100). Null when LLM not used.
        coherenceScore:
          type: ["integer", "null"]
          description: LLM coherence score (0-100). Null when LLM not used.
        pocValidity:
          type: ["integer", "null"]
          description: LLM PoC validity score (0-100). Null when LLM not used.
        domainCoherence:
          type: ["integer", "null"]
          description: LLM domain coherence score (0-100). Null when LLM not used.
        fusionWeights:
          type: object
          description: |
            Task #959: Per-engine weights actually used by the diagnostic on/off
            recalculator on the results page. Keys are engine identifiers
            (currently `linguistic`, `factual`, `template`, `llm`); values are
            the linear weights that sum to 1.0. The server is the single source
            of truth — the EngineTogglePanel consumes these instead of
            hard-coded defaults so calibration changes propagate to the UI
            without a client release.
          additionalProperties:
            type: number
          example:
            linguistic: 0.3
            factual: 0.3
            template: 0.15
            llm: 0.25
      required:
        - linguistic
        - factual
        - template
        - quality

    HumanIndicator:
      type: object
      properties:
        type:
          type: string
          description: Human indicator type (e.g. human_contractions, human_terse_style)
        description:
          type: string
          description: Human-readable description of the human signal
        weight:
          type: integer
          description: Negative weight (reduces slop score)
        matched:
          type: ["string", "null"]
          description: The specific text that triggered this indicator
      required:
        - type
        - description
        - weight

    EvidenceItem:
      type: object
      properties:
        type:
          type: string
          description: Evidence type identifier (e.g. ai_phrase, placeholder_url, severity_inflation)
        description:
          type: string
          description: Human-readable description of the signal
        weight:
          type: integer
          description: Weight/importance of this signal (higher = more significant)
        matched:
          type: ["string", "null"]
          description: The specific text or pattern that was matched
        markers:
          type: array
          items:
            type: string
          description: |
            Task #431: optional structured per-marker IDs for evidence types that aggregate multiple impossibility tells
            into a single signal (today: `hallucination_impossible_http_response`). The triage UI renders one
            badge plus a plain-language tooltip per marker; non-UI surfaces (logs, exports, markdown reports)
            keep using `description`. Omitted when the signal does not aggregate per-marker tells.
        context:
          type: object
          description: |
            Task #435: optional structured payload for richer rendering. Today
            only `hallucination_structural_fabrication` populates this — the
            results UI uses `context.markers` to render one bullet per
            fabrication tell that fired (with each marker's id +
            human-readable description), without regex-parsing the joined
            description string.
          properties:
            markers:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: string
                  description:
                    type: string
                required:
                  - id
                  - description
      required:
        - type
        - description
        - weight

    TriageRecommendation:
      type: object
      properties:
        action:
          type: string
          enum:
            [
              AUTO_CLOSE,
              MANUAL_REVIEW,
              CHALLENGE_REPORTER,
              PRIORITIZE,
              STANDARD_TRIAGE,
            ]
          description: Recommended triage action based on score, confidence, and verification results
        reason:
          type: string
          description: Why this action was recommended
        note:
          type: string
          description: Detailed guidance for the triager
        challengeQuestions:
          type: array
          items:
            $ref: "#/components/schemas/ChallengeQuestion"
          description: Questions to send to the reporter to verify legitimacy (1-4 questions based on detected issues)
        temporalSignals:
          type: array
          items:
            $ref: "#/components/schemas/TemporalSignal"
          description: Behavioral signals based on CVE publication timing
        templateMatch:
          oneOf:
            - $ref: "#/components/schemas/TemplateMatch"
            - type: "null"
          description: Template reuse detection result. Null when no template match found.
        matrixInputs:
          oneOf:
            - $ref: "#/components/schemas/TriageMatrixInputs"
            - type: "null"
          description: The v3.6.0 matrix inputs (composite, engine 2 substance, verification ratio, strong-evidence count) that drove the triage decision. Null on legacy reports stored before v3.6.0 composite scoring.
        revision:
          oneOf:
            - $ref: "#/components/schemas/RevisionResult"
            - type: "null"
          description: Revision tracking result — detected when a report is a revision of a recent submission. Null when not a revision.
      required:
        - action
        - reason
        - note
        - challengeQuestions
        - temporalSignals
        - matrixInputs

    TriageMatrixInputs:
      type: object
      description: The four matrix inputs that drove the v3.6.0 triage decision.
      properties:
        compositeScore:
          type: number
          description: Composite score 0..100, higher = better.
        engine2Score:
          type: number
          description: Engine 2 (Technical Substance Analyzer) score 0..100.
        verificationRatio:
          type: number
          description: Verified / (verified + not_found) ratio over referenced_in_report checks (0..1).
        strongEvidenceCount:
          type: integer
          description: Count of strong evidence signals (CRASH_OUTPUT, STACK_TRACE, CODE_DIFF, etc.).
      required:
        - compositeScore
        - engine2Score
        - verificationRatio
        - strongEvidenceCount

    ChallengeQuestion:
      type: object
      properties:
        category:
          type: string
          description: Question category (missing_file, missing_function, nvd_plagiarism, invalid_cve, placeholder_poc, severity_inflation, fabricated_output)
        question:
          type: string
          description: The question to send to the reporter
        context:
          type: string
          description: Context about why this question was generated
      required:
        - category
        - question
        - context

    TemporalSignal:
      type: object
      properties:
        cveId:
          type: string
        publishedDate:
          type: string
          format: date-time
        hoursSincePublication:
          type: number
        signal:
          type: string
          enum: [suspiciously_fast, fast_turnaround, normal]
        weight:
          type: integer
      required:
        - cveId
        - publishedDate
        - hoursSincePublication
        - signal
        - weight

    TemplateMatch:
      type: object
      properties:
        templateHash:
          type: string
        matchedReportIds:
          type: array
          items:
            type: integer
        weight:
          type: integer
      required:
        - templateHash
        - matchedReportIds
        - weight

    RevisionResult:
      type: object
      properties:
        originalReportId:
          type: integer
        originalScore:
          type: integer
        similarity:
          type: number
        scoreChange:
          type: integer
          description: Score change from original (negative = improved, positive = worsened)
        direction:
          type: string
          enum: [improved, worsened, unchanged]
        changeSummary:
          type: string
          description: Brief summary of what changed between original and revision
      required:
        - originalReportId
        - originalScore
        - similarity
        - scoreChange
        - direction

    ReproStep:
      type: object
      properties:
        order:
          type: integer
        instruction:
          type: string
        note:
          type: ["string", "null"]
        source:
          type: ["string", "null"]
          enum: [heuristic, llm, null]
      required:
        - order
        - instruction

    ReproGuidance:
      type: object
      properties:
        vulnClass:
          type: string
        confidence:
          type: number
        steps:
          type: array
          items:
            $ref: "#/components/schemas/ReproStep"
        environment:
          type: array
          items:
            type: string
        tools:
          type: array
          items:
            type: string
      required:
        - vulnClass
        - confidence
        - steps
        - environment
        - tools

    GapItem:
      type: object
      properties:
        category:
          type: string
        severity:
          type: string
          enum: [critical, important, minor]
        description:
          type: string
        suggestion:
          type: string
        triagerGuidance:
          type: string
        reporterGuidance:
          type: string
        audience:
          type: ["string", "null"]
          enum: [triager, reporter, both, null]
      required:
        - category
        - severity
        - description
        - suggestion
        - triagerGuidance
        - reporterGuidance

    DontMissItem:
      type: object
      properties:
        area:
          type: string
        warning:
          type: string
        reason:
          type: string
      required:
        - area
        - warning
        - reason

    ReporterFeedbackItem:
      type: object
      properties:
        tone:
          type: string
          enum: [positive, neutral, concern]
        message:
          type: string
        priority:
          type: ["integer", "null"]
      required:
        - tone
        - message

    LLMTriageGuidance:
      type: object
      properties:
        reproSteps:
          type: array
          items:
            type: string
        environment:
          type: array
          items:
            type: string
        expectedBehavior:
          type: string
        testingTips:
          type: array
          items:
            type: string
        missingInfo:
          type: array
          items:
            type: string
        dontMiss:
          type: array
          items:
            type: string
        reporterFeedback:
          type: string
      required:
        - reproSteps
        - environment
        - expectedBehavior
        - testingTips
        - missingInfo
        - dontMiss
        - reporterFeedback

    ReporterFeedbackSummary:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ReporterFeedbackItem"
        clarityScore:
          type: integer
        actionability:
          type: string
          enum: [high, medium, low]
      required:
        - items
        - clarityScore
        - actionability

    ReproRecipeTarget:
      type: object
      description: Identified target project for reproduction
      properties:
        name:
          type: string
        version:
          oneOf:
            - type: string
            - type: "null"
        source:
          oneOf:
            - type: string
            - type: "null"
          description: Repository URL (GitHub/GitLab)
        language:
          oneOf:
            - type: string
            - type: "null"
        packageManager:
          oneOf:
            - type: string
            - type: "null"
      required:
        - name

    HardwareComponent:
      type: object
      description: Detected hardware component relevant to the vulnerability
      properties:
        type:
          type: string
          enum: [cpu, server, network, iot, storage, peripheral, embedded, gpu]
        vendor:
          type: string
        model:
          oneOf:
            - type: string
            - type: "null"
        productUrl:
          oneOf:
            - type: string
            - type: "null"
          description: URL to the product page for quick reference
        emulationOptions:
          type: array
          items:
            type: string
          description: Suggested emulation or virtual testing approaches
        notes:
          type: array
          items:
            type: string
      required:
        - type
        - vendor
        - emulationOptions
        - notes

    ReproRecipe:
      type: object
      description: Structured reproduction recipe — setup commands, PoC script, and Dockerfile for reproducing the reported vulnerability
      properties:
        title:
          type: string
        target:
          oneOf:
            - $ref: "#/components/schemas/ReproRecipeTarget"
            - type: "null"
        setupCommands:
          type: array
          items:
            type: string
          description: Shell commands to set up the target environment
        pocScript:
          oneOf:
            - type: string
            - type: "null"
          description: Extracted or generated PoC as a runnable script
        pocLanguage:
          oneOf:
            - type: string
            - type: "null"
          description: Language of the PoC script (bash, python, etc.)
        expectedOutput:
          oneOf:
            - type: string
            - type: "null"
          description: What the triager should observe if the vulnerability is real
        dockerfile:
          oneOf:
            - type: string
            - type: "null"
          description: Generated Dockerfile for containerized reproduction
        notes:
          type: array
          items:
            type: string
          description: Warnings, prerequisites, and verification status
        hardware:
          type: array
          items:
            $ref: "#/components/schemas/HardwareComponent"
          description: Detected hardware components with emulation guidance
      required:
        - title
        - setupCommands
        - notes
        - hardware

    LLMClaims:
      type: object
      description: Extracted claims from the vulnerability report, identified by LLM analysis.
      properties:
        claimedProject:
          type: ["string", "null"]
          description: The project the report claims to be about.
        claimedVersion:
          type: ["string", "null"]
          description: The version of the project claimed.
        claimedFiles:
          type: array
          items:
            type: string
          description: File paths referenced in the report.
        claimedFunctions:
          type: array
          items:
            type: string
          description: Function names referenced in the report.
        claimedLineNumbers:
          type: array
          items:
            type: integer
          description: Line numbers referenced in the report.
        claimedCVEs:
          type: array
          items:
            type: string
          description: CVE or CWE identifiers referenced.
        claimedImpact:
          type: ["string", "null"]
          description: The impact type claimed (RCE, data_leak, DoS, auth_bypass, etc.).
        cvssScore:
          type: ["number", "null"]
          description: CVSS score claimed in the report.
        hasPoC:
          type: boolean
          description: Whether a proof-of-concept is included.
        pocTargetsClaimedLibrary:
          type: boolean
          description: Whether the PoC actually exercises the claimed library.
        hasAsanOutput:
          type: boolean
          description: Whether AddressSanitizer or similar output is included.
        asanFromClaimedProject:
          type: boolean
          description: Whether the sanitizer output appears to be from the claimed project.
        selfDisclosesAI:
          type: boolean
          description: Whether the reporter mentions using AI assistance.
        complianceBuzzwords:
          type: array
          items:
            type: string
          description: Compliance frameworks mentioned (GDPR, PCI-DSS, HIPAA, etc.).
        complianceRelevance:
          type: string
          enum: ["high", "medium", "low", "none"]
          description: Whether the cited compliance frameworks are relevant to the project type.

    LLMSubstanceScores:
      type: object
      description: Substance-based scoring dimensions from LLM analysis.
      properties:
        pocValidity:
          type: integer
          minimum: 0
          maximum: 100
          description: Does the PoC actually test the claimed vulnerability? 0 = no PoC or doesn't reference the library.
        claimSpecificity:
          type: integer
          minimum: 0
          maximum: 100
          description: Are claims specific AND verifiable? Fabricated specifics score lower than vague claims.
        domainCoherence:
          type: integer
          minimum: 0
          maximum: 100
          description: Do claims make sense for the project's architecture and purpose?
        substanceScore:
          type: integer
          minimum: 0
          maximum: 100
          description: Weighted average of pocValidity, claimSpecificity, and domainCoherence.
        coherenceScore:
          type: integer
          minimum: 0
          maximum: 100
          description: How well all claims fit together as a coherent whole.
      required:
        - pocValidity
        - claimSpecificity
        - domainCoherence
        - substanceScore
        - coherenceScore

    TriageAssistant:
      type: object
      properties:
        reproGuidance:
          oneOf:
            - $ref: "#/components/schemas/ReproGuidance"
            - type: "null"
        gaps:
          type: array
          items:
            $ref: "#/components/schemas/GapItem"
        dontMiss:
          type: array
          items:
            $ref: "#/components/schemas/DontMissItem"
        reporterFeedback:
          type: array
          items:
            $ref: "#/components/schemas/ReporterFeedbackItem"
        reporterFeedbackSummary:
          $ref: "#/components/schemas/ReporterFeedbackSummary"
        llmTriageGuidance:
          oneOf:
            - $ref: "#/components/schemas/LLMTriageGuidance"
            - type: "null"
        reproRecipe:
          oneOf:
            - $ref: "#/components/schemas/ReproRecipe"
            - type: "null"
          description: Structured reproduction recipe with setup commands, PoC script, and optional Dockerfile
      required:
        - gaps
        - dontMiss
        - reporterFeedback
        - reporterFeedbackSummary

    SubmitFeedbackBody:
      type: object
      properties:
        reportId:
          type: integer
          description: The report ID this feedback is about (optional)
        rating:
          type: integer
          minimum: 1
          maximum: 5
          description: Rating from 1 (not useful) to 5 (very useful)
        helpful:
          type: boolean
          description: Whether the tool was helpful overall
        comment:
          type: string
          maxLength: 1000
          description: Optional free-text suggestions for improvement
      required:
        - rating
        - helpful

    SubmitFeedbackWithChallenge:
      type: object
      properties:
        challengeId:
          type: string
          description: The challenge ID from GET /feedback/challenge
        challengeSolution:
          type: string
          description: The computed solution to the proof-of-work challenge
        reportId:
          type: integer
          description: The report ID this feedback is about (optional)
        rating:
          type: integer
          minimum: 1
          maximum: 5
          description: Rating from 1 (not useful) to 5 (very useful)
        helpful:
          type: boolean
          description: Whether the tool was helpful overall
        comment:
          type: string
          maxLength: 1000
          description: Optional free-text suggestions for improvement
      required:
        - challengeId
        - challengeSolution
        - rating
        - helpful

    FeedbackChallenge:
      type: object
      properties:
        challengeId:
          type: string
        nonce:
          type: string
        difficulty:
          type: integer
        prefix:
          type: string
        expiresAt:
          type: number
      required: [challengeId, nonce, difficulty, prefix, expiresAt]

    FeedbackResponse:
      type: object
      properties:
        id:
          type: integer
        message:
          type: string
      required:
        - id
        - message

    FeedbackAnalytics:
      type: object
      properties:
        summary:
          type: object
          properties:
            totalFeedback:
              type: integer
            avgRating:
              type: number
            helpfulCount:
              type: integer
            notHelpfulCount:
              type: integer
            helpfulnessRate:
              type: number
            withComments:
              type: integer
            linkedToReport:
              type: integer
          required:
            [
              totalFeedback,
              avgRating,
              helpfulCount,
              notHelpfulCount,
              helpfulnessRate,
              withComments,
              linkedToReport,
            ]
        ratingDistribution:
          type: object
          additionalProperties:
            type: integer
        dailyTrend:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
              count:
                type: integer
              avgRating:
                type: number
              helpfulPct:
                type: number
            required: [date, count, avgRating, helpfulPct]
        scoreCorrelation:
          type: array
          items:
            type: object
            properties:
              scoreBucket:
                type: string
              avgRating:
                type: number
              helpfulPct:
                type: number
              count:
                type: integer
            required: [scoreBucket, avgRating, helpfulPct, count]
        outliers:
          type: array
          items:
            type: object
            properties:
              feedbackId:
                type: integer
              rating:
                type: integer
              helpful:
                type: boolean
              comment:
                type: string
                nullable: true
              feedbackDate:
                type: string
                format: date-time
              slopScore:
                type: integer
              slopTier:
                type: string
              qualityScore:
                type: integer
            required:
              [feedbackId, rating, helpful, slopScore, slopTier, qualityScore]
        recentFeedback:
          type: array
          items:
            type: object
            properties:
              feedbackId:
                type: integer
              rating:
                type: integer
              helpful:
                type: boolean
              comment:
                type: string
                nullable: true
              createdAt:
                type: string
                format: date-time
              slopScore:
                type: integer
                nullable: true
              slopTier:
                type: string
                nullable: true
            required: [feedbackId, rating, helpful, createdAt]
      required:
        [
          summary,
          ratingDistribution,
          dailyTrend,
          scoreCorrelation,
          outliers,
          recentFeedback,
        ]

    HoldoutEvalPartition:
      type: object
      description: |
        One partition (holdout or in-sample) of the binary-classifier
        evaluation. Counts are always present; ratio metrics are nullable
        when the relevant denominator is zero.
      properties:
        totalFeedback:
          type: integer
        tp:
          type: integer
          description: Predicted slop AND actually slop.
        fp:
          type: integer
          description: Predicted slop AND not actually slop.
        fn:
          type: integer
          description: Not predicted slop AND actually slop.
        tn:
          type: integer
          description: Not predicted slop AND not actually slop.
        precision:
          type: number
          nullable: true
          description: tp / (tp + fp); null when no rows were predicted slop.
        recall:
          type: number
          nullable: true
          description: tp / (tp + fn); null when no rows were actually slop.
        f1:
          type: number
          nullable: true
          description: Harmonic mean of precision and recall; null when either is null.
        accuracy:
          type: number
          nullable: true
          description: (tp + tn) / total; null when total is zero.
      required: [totalFeedback, tp, fp, fn, tn, precision, recall, f1, accuracy]

    HoldoutEvalResponse:
      type: object
      properties:
        thresholds:
          type: object
          properties:
            scoreThreshold:
              type: number
              description: slopScore at/above which the engine "predicts slop".
            ratingThreshold:
              type: integer
              description: User rating at/below which the row is treated as "actually slop".
            description:
              type: string
          required: [scoreThreshold, ratingThreshold, description]
        holdout:
          $ref: "#/components/schemas/HoldoutEvalPartition"
        inSample:
          $ref: "#/components/schemas/HoldoutEvalPartition"
        holdoutFraction:
          type: number
          description: Fraction of feedback rows assigned to the holdout split (0.2 in v1).
      required: [thresholds, holdout, inSample, holdoutFraction]

    ScoringConfigItem:
      type: object
      properties:
        version:
          type: string
        createdAt:
          type: string
        prior:
          type: number
        floor:
          type: number
        ceiling:
          type: number
        axisThresholds:
          type: object
          additionalProperties:
            type: number
        tierThresholds:
          type: object
          properties:
            low:
              type: number
            high:
              type: number
          required: [low, high]
        fabricationBoost:
          type: number
        description:
          type: string
      required:
        [
          version,
          createdAt,
          prior,
          floor,
          ceiling,
          axisThresholds,
          tierThresholds,
          fabricationBoost,
          description,
        ]

    ScoringConfigResponse:
      type: object
      properties:
        current:
          $ref: "#/components/schemas/ScoringConfigItem"
        history:
          type: array
          items:
            $ref: "#/components/schemas/ScoringConfigItem"
      required: [current, history]

    CalibrationAuthStatus:
      description: |
        Task #117 — Snapshot of the calibration token gate as observed by the
        API server for THIS request. The dashboard polls this probe to render
        a "Reviewer token: configured / missing / invalid" indicator before
        the reviewer triggers a mutation that would otherwise 401.
      type: object
      properties:
        serverRequiresToken:
          type: boolean
          description: |
            True when `CALIBRATION_TOKEN` is set on the API process. When
            false, every calibration mutation is open (single-reviewer / local
            dev fallback) and the UI does not need to send a token.
        tokenPresented:
          type: boolean
          description: |
            True when the request included a token via either the
            `X-Calibration-Token` header or `Authorization: Bearer <token>`.
        tokenValid:
          type: boolean
          description: |
            True when a token was presented AND it matches the server's
            configured token. Always false when the server has no token
            configured (since there is nothing to compare against).
        mutationsAllowed:
          type: boolean
          description: |
            Derived: `!serverRequiresToken || tokenValid`. When false, the
            UI should warn the reviewer that calibration mutations will be
            rejected with 401.
      required:
        [serverRequiresToken, tokenPresented, tokenValid, mutationsAllowed]

    BucketAnalysis:
      type: object
      properties:
        bucket:
          type: string
        scoreRange:
          type: array
          items:
            type: number
        feedbackCount:
          type: integer
        avgRating:
          type: number
        helpfulPct:
          type: number
        meetsThreshold:
          type: boolean
        signal:
          type: string
          enum: [accurate, over-scoring, under-scoring, insufficient-data]
        ratingDeviation:
          type: number
      required:
        [
          bucket,
          scoreRange,
          feedbackCount,
          avgRating,
          helpfulPct,
          meetsThreshold,
          signal,
          ratingDeviation,
        ]

    CalibrationSuggestion:
      type: object
      properties:
        parameter:
          type: string
        currentValue:
          type: number
        suggestedValue:
          type: number
        reason:
          type: string
        confidence:
          type: string
          enum: [low, medium, high]
        basedOnCount:
          type: integer
      required:
        [
          parameter,
          currentValue,
          suggestedValue,
          reason,
          confidence,
          basedOnCount,
        ]

    PublicDriftSummaryWeek:
      type: object
      description: |
        Single weekly data point in the public drift sparkline. Contains
        only the UTC Monday week-start and the redacted T1−T3 composite
        spread; no bucket counts, per-family means, or sample IDs.
      properties:
        weekStart:
          type: string
          description: ISO date (YYYY-MM-DD) of the UTC Monday that starts the week.
        spread:
          type: number
          nullable: true
          description: |
            Rounded T1−T3 composite mean spread for the week. `null`
            when either bucket failed to meet the minimum sample size,
            so the public widget can skip ineligible weeks without
            leaking the underlying counts.
      required: [weekStart, spread]

    PublicDriftSummary:
      type: object
      description: |
        Public-safe wrapper around the internal AVRI drift compute. Only
        weekly redacted spreads + a current/previous/delta summary are
        exposed; reviewer-only fields (per-family means, flags, bucket
        counts, thresholds, bucketing-note, runbook path) are stripped
        upstream so this DTO can be served on `/transparency` without
        leaking internal calibration signals.
      properties:
        generatedAt:
          type: string
          description: ISO 8601 timestamp when the summary was computed.
        weeks:
          type: array
          maxItems: 12
          description: |
            Up to the last 12 eligible weeks of `{ weekStart, spread }`
            data points, oldest-first. Empty when no weekly aggregate
            has been computed yet so the widget can render an empty
            state.
          items:
            $ref: "#/components/schemas/PublicDriftSummaryWeek"
        currentSpread:
          type: number
          nullable: true
          description: Spread for the most recent eligible week, or `null` when unavailable.
        previousSpread:
          type: number
          nullable: true
          description: Spread for the week prior to the most recent eligible week, or `null` when unavailable.
        delta:
          type: number
          nullable: true
          description: |
            `currentSpread − previousSpread`, rounded to 1 decimal.
            `null` when either side is unavailable.
        hasCurrentWeek:
          type: boolean
          description: |
            True when the most recent eligible week in `weeks` matches
            the UTC Monday of the current week. Lets the widget show a
            "this week" badge vs. an "awaiting this week's aggregate"
            hint without exposing report counts.
      required:
        [
          generatedAt,
          weeks,
          currentSpread,
          previousSpread,
          delta,
          hasCurrentWeek,
        ]

    AvriDriftFamilyMean:
      type: object
      properties:
        family:
          type: string
        count:
          type: integer
        mean:
          type: number
      required: [family, count, mean]

    AvriDriftWeekBucketStats:
      type: object
      properties:
        count:
          type: integer
        mean:
          type: number
          nullable: true
      required: [count, mean]

    AvriDriftWeekBucketPerFamily:
      type: object
      properties:
        t1:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftFamilyMean"
        t3:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftFamilyMean"
      required: [t1, t3]

    AvriDriftWeekBucket:
      type: object
      properties:
        weekStart:
          type: string
          description: ISO date (YYYY-MM-DD) of the UTC Monday that starts the week.
        reportCount:
          type: integer
        t1:
          $ref: "#/components/schemas/AvriDriftWeekBucketStats"
        t3:
          $ref: "#/components/schemas/AvriDriftWeekBucketStats"
        gap:
          type: number
          nullable: true
        perFamily:
          $ref: "#/components/schemas/AvriDriftWeekBucketPerFamily"
        gapEligible:
          type: boolean
      required: [weekStart, reportCount, t1, t3, gap, perFamily, gapEligible]

    AvriDriftFlag:
      type: object
      properties:
        weekStart:
          type: string
        kind:
          type: string
          enum: [GAP_BELOW_45, FAMILY_MEAN_SHIFT]
        detail:
          type: string
      required: [weekStart, kind, detail]

    AvriDriftThresholds:
      type: object
      properties:
        gapWarn:
          type: number
        familyShiftWarn:
          type: number
        minBucketSize:
          type: integer
      required: [gapWarn, familyShiftWarn, minBucketSize]

    AvriDriftReport:
      type: object
      properties:
        generatedAt:
          type: string
        weeksRequested:
          type: integer
        totalReportsScanned:
          type: integer
        cohort:
          type: string
          enum: [avri_on_only]
        bucketingNote:
          type: string
        thresholds:
          $ref: "#/components/schemas/AvriDriftThresholds"
        weeks:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftWeekBucket"
        flags:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftFlag"
        runbookPath:
          type: string
      required:
        [
          generatedAt,
          weeksRequested,
          totalReportsScanned,
          cohort,
          bucketingNote,
          thresholds,
          weeks,
          flags,
          runbookPath,
        ]

    AvriDriftNotificationRecord:
      type: object
      description: |
        Single dedup entry from `data/avri-drift-notifications.json`. The
        `key` is the stable per-flag identifier (week + kind, plus bucket +
        family for `FAMILY_MEAN_SHIFT`) used to suppress repeat
        notifications.
      properties:
        key:
          type: string
          description: Stable per-flag dedup key. Pass this back to /rearm to re-arm the flag.
        weekStart:
          type: string
          description: ISO date (YYYY-MM-DD) for the Monday that started the flag's week.
        kind:
          type: string
          enum: [GAP_BELOW_45, FAMILY_MEAN_SHIFT]
        notifiedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when the flag was first dispatched.
        detail:
          type: string
          description: Original flag detail string at the time of the first notification.
      required: [key, weekStart, kind, notifiedAt, detail]

    AvriDriftNotificationsList:
      type: object
      properties:
        notified:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftNotificationRecord"
        total:
          type: integer
      required: [notified, total]

    AvriDriftRearmBody:
      type: object
      properties:
        keys:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: string
            minLength: 1
          description: Dedup keys (from AvriDriftNotificationRecord.key) to re-arm.
        reviewer:
          type: string
          maxLength: 200
          description: |
            Optional reviewer name recorded in the re-arm audit log.
            Empty/whitespace-only values are treated as absent.
        rationale:
          type: string
          maxLength: 500
          description: |
            Optional free-form rationale recorded in the re-arm audit
            log so reviewers can explain why a flag was re-armed.
            Empty/whitespace-only values are treated as absent.
      required: [keys]

    AvriDriftRearmAuditEntry:
      type: object
      description: |
        Single entry in the re-arm audit log. Preserves the original
        notification metadata of the dedup record that was removed
        plus the wall-clock + reviewer context for the re-arm action
        itself, so the log stays useful even after the matching dedup
        record is gone.
      properties:
        key:
          type: string
          description: Dedup key that was re-armed.
        weekStart:
          type: string
          description: ISO date (YYYY-MM-DD) for the Monday that started the flag's week.
        kind:
          type: string
          enum: [GAP_BELOW_45, FAMILY_MEAN_SHIFT]
        originalNotifiedAt:
          type: string
          format: date-time
          description: ISO timestamp when the original notification first dispatched.
        originalDetail:
          type: string
          description: Original flag detail string at the time of the first notification.
        rearmedAt:
          type: string
          format: date-time
          description: ISO timestamp when the entry was re-armed.
        rearmedBy:
          type: string
          description: Reviewer name supplied with the re-arm call (omitted when not provided).
        rationale:
          type: string
          description: Free-form rationale supplied with the re-arm call (omitted when not provided).
      required:
        [key, weekStart, kind, originalNotifiedAt, originalDetail, rearmedAt]

    AvriDriftRearmHistoryList:
      type: object
      description: |
        Snapshot of the persisted re-arm audit log. Capped at 200
        entries (oldest trimmed first); newest entries are at the END
        of the array (callers reverse for newest-first display).
      properties:
        history:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftRearmAuditEntry"
        total:
          type: integer
      required: [history, total]

    AvriDriftRearmResponse:
      type: object
      properties:
        rearmed:
          type: integer
          description: Number of dedup entries that were re-armed (i.e. removed from the dedup state).
        notFound:
          type: array
          items:
            type: string
          description: Keys from the request that did not match any persisted entry.
        remaining:
          type: integer
          description: Total entries left in the dedup state after the re-arm.
        removed:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftNotificationRecord"
          description: Full records that were re-armed, in the order they appeared in the dedup state.
        notified:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftNotificationRecord"
          description: Refreshed dedup state snapshot after the re-arm so the UI can update without an extra GET.
        auditEntries:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftRearmAuditEntry"
          description: |
            Audit entries appended to the persisted log for this call
            (one per matched key). Empty when nothing matched.
        rearmHistory:
          type: array
          items:
            $ref: "#/components/schemas/AvriDriftRearmAuditEntry"
          description: |
            Refreshed re-arm audit log after this call so the UI can
            render the "Recently re-armed" panel without an extra
            GET. Capped at 200 entries.
      required:
        [
          rearmed,
          notFound,
          remaining,
          removed,
          notified,
          auditEntries,
          rearmHistory,
        ]

    CalibrationAuthBruteForceAlertEntry:
      type: object
      description: |
        Single entry in the in-process ring buffer of dispatched
        calibration-auth brute-force alerts. Mirrors the webhook
        payload (sans the constant `event` discriminator and the
        static `recommendedActions` runbook copy).
      properties:
        detectedAt:
          type: string
          format: date-time
          description: ISO timestamp when the alert tripped (i.e. the threshold-crossing wrong-token event).
        ip:
          type: string
          description: Source IP that crossed the per-IP wrong-token threshold.
        windowMs:
          type: integer
          description: Sliding window (ms) used to count wrong-token events.
        threshold:
          type: integer
          description: Number of wrong-token events within `windowMs` that triggered the alert.
        wrongTokenCount:
          type: integer
          description: Total wrong-token events from the IP within the window at the moment the alert fired.
        rejectionsByStatus:
          type: object
          properties:
            "401":
              type: integer
            "429":
              type: integer
          required: ["401", "429"]
          description: Tally of in-window rejections split by HTTP status code.
        rejectionsByGate:
          type: object
          properties:
            mutation:
              type: integer
            "strict-read":
              type: integer
          required: [mutation, "strict-read"]
          description: Tally of in-window rejections split by which calibration gate fired.
        firstSeenAt:
          type: string
          format: date-time
          description: ISO timestamp of the oldest in-window wrong-token event from this IP.
        lastSeenAt:
          type: string
          format: date-time
          description: ISO timestamp of the newest in-window wrong-token event from this IP (the one that crossed the threshold).
        lastRoute:
          type: string
          description: Route that the threshold-crossing request hit.
        lastMethod:
          type: string
          description: HTTP method of the threshold-crossing request.
        runbookUrl:
          type: string
          description: Operator runbook URL surfaced in the alert payload.
        ack:
          $ref: "#/components/schemas/CalibrationAuthBruteForceAlertAck"
      required:
        - detectedAt
        - ip
        - windowMs
        - threshold
        - wrongTokenCount
        - rejectionsByStatus
        - rejectionsByGate
        - firstSeenAt
        - lastSeenAt
        - lastRoute
        - lastMethod
        - runbookUrl

    CalibrationAuthBruteForceAlertAck:
      type: object
      description: |
        Reviewer acknowledgement attached to a dispatched calibration
        auth brute-force alert. Mirrors the shape of the AVRI drift
        re-arm audit entries (reviewer + free-form note + wall-clock
        timestamp) so the dashboard can render both audit trails with
        the same helpers.
      properties:
        ackedAt:
          type: string
          format: date-time
          description: ISO timestamp when the reviewer acknowledged the alert.
        ackedBy:
          type: string
          description: Reviewer name supplied with the ack call (omitted when not provided).
        note:
          type: string
          description: Free-form note supplied with the ack call (omitted when not provided).
      required: [ackedAt]

    CalibrationAuthBruteForceAlertAckBody:
      type: object
      description: |
        Identifies the alert by `(ip, detectedAt)` — the same tuple
        the recent-alerts panel uses as a React key — plus optional
        reviewer attribution and a free-form note.
      properties:
        ip:
          type: string
          minLength: 1
          description: Source IP from the alert row to acknowledge.
        detectedAt:
          type: string
          format: date-time
          description: The `detectedAt` of the alert row to acknowledge.
        reviewer:
          type: string
          maxLength: 200
          description: Reviewer display name to record on the ack (optional).
        note:
          type: string
          maxLength: 500
          description: Free-form note describing the disposition (e.g. "false alarm — office NAT").
      required: [ip, detectedAt]

    CalibrationAuthBruteForceAlertAckResponse:
      type: object
      description: |
        Echoes the now-acked alert (with the `ack` field populated)
        so the calibration UI can update the row without re-fetching
        the entire ring buffer.
      properties:
        alert:
          $ref: "#/components/schemas/CalibrationAuthBruteForceAlertEntry"
      required: [alert]

    CalibrationAuthBruteForceAlertAckConflict:
      type: object
      description: |
        Returned with HTTP 409 when the alert identified by the
        request body has already been acknowledged by another
        reviewer. Includes the existing `alert` (with its `ack`
        populated) so the UI can show who got there first.
      properties:
        error:
          type: string
        reason:
          type: string
          enum: [already-acked]
        alert:
          $ref: "#/components/schemas/CalibrationAuthBruteForceAlertEntry"
      required: [error, reason, alert]

    CalibrationAuthBruteForceAlertsList:
      type: object
      description: |
        Newest-first snapshot of the in-process recent-alerts ring
        buffer. `bufferSize` is the absolute cap on retained entries
        (older alerts are evicted FIFO); `limit` is the per-request
        cap actually applied.
      properties:
        alerts:
          type: array
          items:
            $ref: "#/components/schemas/CalibrationAuthBruteForceAlertEntry"
        total:
          type: integer
          description: Number of alerts returned (`<= limit`).
        limit:
          type: integer
          description: Effective per-request cap that was applied.
        bufferSize:
          type: integer
          description: Maximum number of entries the in-process buffer retains.
      required: [alerts, total, limit, bufferSize]

    AvriDriftSchedulerStatus:
      type: object
      description: |
        Per-replica status of the AVRI drift-notification scheduler.
        Timestamps are ISO 8601; `null` is used wherever the field is
        not yet meaningful (e.g. `lastTickAt` before the first tick,
        `nextTickAt` while the scheduler is stopped).

        `replicaId` + `hostname` identify the server replica that owns
        this snapshot so a multi-replica deploy can render one row per
        replica. `heartbeatAt` is the wall-clock at which the snapshot
        was last persisted to the shared state file (null only for the
        live in-memory snapshot of the responding replica before its
        first persisted heartbeat).
      properties:
        replicaId:
          type: string
          description: |
            Stable per-process identity for the replica that owns this
            snapshot (e.g. `${hostname}-${bootHex}`, overridable via
            the `AVRI_REPLICA_ID` environment variable).
        hostname:
          type: string
          description: Hostname of the replica that owns this snapshot.
        heartbeatAt:
          type: string
          format: date-time
          nullable: true
          description: |
            ISO timestamp at which this snapshot was last persisted to
            the shared state file. Null only for the live in-memory
            snapshot of the responding replica before its first
            persisted heartbeat.
        schedulerStarted:
          type: boolean
          description: True once the scheduler has been armed in this process.
        startedAt:
          type: string
          format: date-time
          nullable: true
          description: ISO timestamp when the scheduler was started, or null when never started in this process.
        intervalMs:
          type: integer
          nullable: true
          description: Configured success-case interval (ms), or null when not started.
        retryIntervalMs:
          type: integer
          nullable: true
          description: Configured retry-on-failure interval (ms), or null when not started.
        webhookConfigured:
          type: boolean
          description: True when the drift webhook URL is currently set; ticks short-circuit when false.
        lastTickAt:
          type: string
          format: date-time
          nullable: true
          description: ISO timestamp of the most recent completed tick, or null when no tick has run yet.
        lastTickOk:
          type: boolean
          nullable: true
          description: True when the last tick succeeded; false on failure; null until the first tick.
        lastTickRanCheck:
          type: boolean
          nullable: true
          description: True when the last tick scanned the database; false when it short-circuited because no webhook is configured; null until the first tick.
        lastTickDispatched:
          type: boolean
          nullable: true
          description: True when the last tick dispatched a webhook; null until the first tick or when no dispatch was attempted.
        lastTickNewFlagCount:
          type: integer
          nullable: true
          description: Number of new flags dispatched on the most recent tick; null until the first tick or when no dispatch was attempted.
        nextTickAt:
          type: string
          format: date-time
          nullable: true
          description: ISO timestamp when the next tick is scheduled to fire; null when the scheduler is stopped or never started.
        ticksCompleted:
          type: integer
          description: Number of ticks that have completed since the scheduler started.
      required:
        - replicaId
        - hostname
        - heartbeatAt
        - schedulerStarted
        - startedAt
        - intervalMs
        - retryIntervalMs
        - webhookConfigured
        - lastTickAt
        - lastTickOk
        - lastTickRanCheck
        - lastTickDispatched
        - lastTickNewFlagCount
        - nextTickAt
        - ticksCompleted

    PerLanguageAgreementEntry:
      type: object
      properties:
        language:
          type: string
        legitTier:
          type: string
        slopTier:
          type: string
        legitTierDelta:
          type: integer
        slopTierDelta:
          type: integer
        legitAgrees:
          type: boolean
        slopAgrees:
          type: boolean
      required:
        [
          language,
          legitTier,
          slopTier,
          legitTierDelta,
          slopTierDelta,
          legitAgrees,
          slopAgrees,
        ]

    PerLanguageAgreementReport:
      type: object
      properties:
        englishLegitTier:
          type: string
        englishSlopTier:
          type: string
        perLanguage:
          type: array
          items:
            $ref: "#/components/schemas/PerLanguageAgreementEntry"
        agreementRate:
          type: number
        fixtureCount:
          type: integer
      required:
        [
          englishLegitTier,
          englishSlopTier,
          perLanguage,
          agreementRate,
          fixtureCount,
        ]

    CalibrationReport:
      type: object
      properties:
        currentConfig:
          $ref: "#/components/schemas/ScoringConfigItem"
        totalFeedbackAnalyzed:
          type: integer
        bucketAnalysis:
          type: array
          items:
            $ref: "#/components/schemas/BucketAnalysis"
        suggestions:
          type: array
          items:
            $ref: "#/components/schemas/CalibrationSuggestion"
        overallHealth:
          type: string
          enum: [good, needs-attention, needs-tuning]
        minFeedbackThreshold:
          type: integer
        perLanguageAgreementRate:
          type: number
        perLanguageAgreement:
          $ref: "#/components/schemas/PerLanguageAgreementReport"
      required:
        [
          currentConfig,
          totalFeedbackAnalyzed,
          bucketAnalysis,
          suggestions,
          overallHealth,
          minFeedbackThreshold,
          perLanguageAgreementRate,
          perLanguageAgreement,
        ]

    ShadowDriftRow:
      type: object
      description: |
        One persisted shadow-score row whose live vs. shadow result
        diverges enough to surface on the reviewer drift dashboard
        (Task #639). A row qualifies when the tier flipped OR the
        score delta is at least 10 points in either direction.
      properties:
        id:
          type: integer
        reportId:
          type: integer
        liveScore:
          type: integer
        liveTier:
          type: string
        shadowScore:
          type: integer
        shadowTier:
          type: string
        scoreDiff:
          type: integer
          description: shadow_score − live_score (positive means shadow is harsher)
        tierDiverged:
          type: boolean
        shadowVersion:
          type: string
        scoredAt:
          type: string
          format: date-time
        fileName:
          type: string
          nullable: true
      required:
        - id
        - reportId
        - liveScore
        - liveTier
        - shadowScore
        - shadowTier
        - scoreDiff
        - tierDiverged
        - shadowVersion
        - scoredAt

    ShadowDriftTotals:
      type: object
      properties:
        total:
          type: integer
        divergent:
          type: integer
        tierFlips:
          type: integer
        scoreFlips:
          type: integer
        legitToSlop:
          type: integer
        slopToLegit:
          type: integer
      required:
        [total, divergent, tierFlips, scoreFlips, legitToSlop, slopToLegit]

    ShadowDriftReport:
      type: object
      description: |
        Reviewer-only listing of shadow-score divergences in the
        lookback window. `enabled` echoes whether
        `SHADOW_SCORING_ENABLED=1` is set on the API server so the UI
        can render an "OFF" hint when no fresh rows are landing.
      properties:
        enabled:
          type: boolean
        generatedAt:
          type: string
          format: date-time
        lookbackDays:
          type: integer
        scoreDeltaThreshold:
          type: integer
          description: Score-delta threshold (in points) above which a row is considered divergent regardless of tier.
        totals:
          $ref: "#/components/schemas/ShadowDriftTotals"
        rows:
          type: array
          items:
            $ref: "#/components/schemas/ShadowDriftRow"
      required:
        [enabled, generatedAt, lookbackDays, scoreDeltaThreshold, totals, rows]

    ScoreStabilityDailyBucket:
      type: object
      description: |
        Per-day flip counts for the score-stability monitor. `total` is
        the number of `report_rescore_log` rows the scheduler wrote on
        that day (i.e. the day's re-score volume); `flips` is the
        subset whose tier changed. The direction-specific counters
        sum to `flips`.
      properties:
        date:
          type: string
          description: ISO date `YYYY-MM-DD` (UTC) the re-scores were written for.
        total:
          type: integer
          description: Number of re-score rows logged that day.
        flips:
          type: integer
          description: Subset of `total` whose tier changed under the new code.
        legitToSlop:
          type: integer
          description: Reports that moved from {Clean, Likely Human} into {Likely Slop, Slop}.
        slopToLegit:
          type: integer
          description: Reports that moved from {Likely Slop, Slop} into {Clean, Likely Human}.
        tightened:
          type: integer
          description: One-step moves toward `slop` (e.g. legit→middle, middle→slop).
        loosened:
          type: integer
          description: One-step moves toward `legit` (e.g. slop→middle, middle→legit).
        lateral:
          type: integer
          description: Tier changes that don't fit any of the above directions.
        flipRate:
          type: number
          description: flips / total, rounded to 4 decimals (0 when total is 0).
      required:
        [
          date,
          total,
          flips,
          legitToSlop,
          slopToLegit,
          tightened,
          loosened,
          lateral,
          flipRate,
        ]

    ScoreStabilitySummary:
      type: object
      description: |
        Reviewer-only summary backing the tier-flip chart on
        `/feedback-analytics`. Aggregates `report_rescore_log` over the
        lookback window into per-day flip counts plus the rolling totals
        the chart's header needs. The `alertThreshold` is echoed back so
        the UI can render the same dashed line the scheduler uses to
        decide whether to page reviewers.
      properties:
        generatedAt:
          type: string
          format: date-time
        lookbackDays:
          type: integer
        alertThreshold:
          type: number
          description: Flip-rate threshold (0..1) above which the scheduler pages reviewers.
        daily:
          type: array
          description: Per-day flip buckets, most-recent first.
          items:
            $ref: "#/components/schemas/ScoreStabilityDailyBucket"
        totals:
          type: object
          properties:
            total: { type: integer }
            flips: { type: integer }
            legitToSlop: { type: integer }
            slopToLegit: { type: integer }
            tightened: { type: integer }
            loosened: { type: integer }
            lateral: { type: integer }
            flipRate: { type: number }
          required:
            [
              total,
              flips,
              legitToSlop,
              slopToLegit,
              tightened,
              loosened,
              lateral,
              flipRate,
            ]
      required: [generatedAt, lookbackDays, alertThreshold, daily, totals]

    ScoreStabilityFlipDetail:
      type: object
      description: |
        A single report whose tier changed during re-scoring on a given
        day. Carries the old/new tier and score plus the flip direction
        so reviewers can assess the regression at a glance before
        clicking through to the report detail view.
      properties:
        reportId:
          type: integer
          description: Primary key of the report in `reports`.
        oldTier:
          type: string
        newTier:
          type: string
        oldScore:
          type: integer
        newScore:
          type: integer
        direction:
          type: string
          enum: [legit_to_slop, slop_to_legit, tightened, loosened, lateral]
      required: [reportId, oldTier, newTier, oldScore, newScore, direction]

    ScoreStabilityDayFlips:
      type: object
      description: |
        Listing of individual tier flips for a single UTC day. Returned
        by the `/score-stability/flips?date=YYYY-MM-DD` drilldown
        endpoint so reviewers can investigate which reports regressed.
      properties:
        date:
          type: string
          description: The requested ISO date.
        flips:
          type: array
          items:
            $ref: "#/components/schemas/ScoreStabilityFlipDetail"
      required: [date, flips]

    ScoreStabilitySchedulerStatus:
      type: object
      description: |
        Per-replica heartbeat for the in-process score-stability
        scheduler. Mirrors `AvriDriftSchedulerStatus` shape (booleans /
        timestamps / small numeric counters only) so it's safe to
        expose on an unauthenticated endpoint alongside the other
        scheduler-status surfaces.
      properties:
        schedulerStarted:
          type: boolean
        schedulerEnabled:
          type: boolean
          description: True when SCORE_STABILITY_SCHEDULER_ENABLED is set; ticks short-circuit when false.
        startedAt:
          type: string
          format: date-time
          nullable: true
        intervalMs:
          type: integer
          nullable: true
        retryIntervalMs:
          type: integer
          nullable: true
        lastTickAt:
          type: string
          format: date-time
          nullable: true
        lastTickOk:
          type: boolean
          nullable: true
        lastTickRanCheck:
          type: boolean
          nullable: true
          description: True when the last tick scanned the database; false when it short-circuited because the scheduler is disabled.
        lastTickScanned:
          type: integer
          nullable: true
        lastTickLogged:
          type: integer
          nullable: true
        lastTickFlips:
          type: integer
          nullable: true
        lastTickFailed:
          type: integer
          nullable: true
        lastAlertDate:
          type: string
          nullable: true
          description: ISO date (`YYYY-MM-DD`) of the most recently evaluated day.
        lastAlertFlipRate:
          type: number
          nullable: true
        lastAlertDispatched:
          type: boolean
          nullable: true
        nextTickAt:
          type: string
          format: date-time
          nullable: true
        ticksCompleted:
          type: integer
      required:
        [
          schedulerStarted,
          schedulerEnabled,
          startedAt,
          intervalMs,
          retryIntervalMs,
          lastTickAt,
          lastTickOk,
          lastTickRanCheck,
          lastTickScanned,
          lastTickLogged,
          lastTickFlips,
          lastTickFailed,
          lastAlertDate,
          lastAlertFlipRate,
          lastAlertDispatched,
          nextTickAt,
          ticksCompleted,
        ]

    NvdRejectedFeedSchedulerStatus:
      type: object
      description: |
        Per-replica heartbeat for the in-process NVD rejected-CVE feed
        refresher. Mirrors the other scheduler-status shapes (booleans /
        timestamps / small numeric counters only) so it's safe to expose
        on an unauthenticated endpoint alongside the other heartbeat
        surfaces.
      properties:
        schedulerStarted:
          type: boolean
        schedulerEnabled:
          type: boolean
          description: True when NVD_REJECTED_FEED_ENABLED is set; ticks short-circuit when false.
        startedAt:
          type: string
          format: date-time
          nullable: true
        intervalMs:
          type: integer
          nullable: true
        retryIntervalMs:
          type: integer
          nullable: true
        lastTickAt:
          type: string
          format: date-time
          nullable: true
        lastTickOk:
          type: boolean
          nullable: true
        lastTickRanTick:
          type: boolean
          nullable: true
          description: True when the last tick actually fetched the feed; false when it short-circuited because the scheduler is disabled.
        lastTickCount:
          type: integer
          nullable: true
          description: Number of rejected-CVE IDs primed into the cache on the last successful tick.
        lastTickDurationMs:
          type: integer
          nullable: true
        loadedFromDisk:
          type: integer
          nullable: true
          description: Number of rejected-CVE IDs primed from the persisted cache at scheduler startup.
        loadedFetchedAt:
          type: string
          nullable: true
          description: The `fetchedAt` timestamp recorded in the persisted cache file at startup (raw string; the file may pre-date strict ISO formatting).
        nextTickAt:
          type: string
          format: date-time
          nullable: true
        ticksCompleted:
          type: integer
      required:
        [
          schedulerStarted,
          schedulerEnabled,
          startedAt,
          intervalMs,
          retryIntervalMs,
          lastTickAt,
          lastTickOk,
          lastTickRanTick,
          lastTickCount,
          lastTickDurationMs,
          loadedFromDisk,
          loadedFetchedAt,
          nextTickAt,
          ticksCompleted,
        ]

    ScoringGateRunDiff:
      type: object
      description: A single report's tier/score diff from a scoring-gate replay run.
      properties:
        id:
          type: integer
        storedTier:
          type: string
        recomputedTier:
          type: string
        storedScore:
          type: number
        recomputedScore:
          type: number
        scoreDelta:
          type: number
      required:
        [
          id,
          storedTier,
          recomputedTier,
          storedScore,
          recomputedScore,
          scoreDelta,
        ]

    ScoringGateRun:
      type: object
      description: |
        A single scoring-gate replay run record. Persisted each time
        `scripts/scoring-gate.sh` executes the replay test so the
        calibration dashboard can render a trend line of flip rates.
      properties:
        id:
          type: integer
        timestamp:
          type: string
          format: date-time
        commit:
          type: string
        totalReports:
          type: integer
        flipCount:
          type: integer
        flipRate:
          type: number
        topDiffs:
          type: array
          items:
            $ref: "#/components/schemas/ScoringGateRunDiff"
      required:
        [id, timestamp, commit, totalReports, flipCount, flipRate, topDiffs]

    ScoringGateRunHistory:
      type: object
      description: |
        Wrapper for the scoring-gate run history list. Runs are returned
        in chronological order (oldest first).
      properties:
        runs:
          type: array
          items:
            $ref: "#/components/schemas/ScoringGateRun"
      required: [runs]

    ApplyCalibrationBody:
      type: object
      properties:
        changes:
          type: object
          description: Object with scoring config changes (prior, floor, ceiling, axisThresholds, tierThresholds, fabricationBoost)
        description:
          type: string
          description: Description of why this change is being made
      required: [changes, description]

    ApplyCalibrationResponse:
      type: object
      properties:
        message:
          type: string
        config:
          $ref: "#/components/schemas/ScoringConfigItem"
      required: [message, config]

    HandwavyCategory:
      type: string
      enum: [absence, hedging, buzzword]
      description: Theme bucket used by the diagnostics panel to group matched phrases.

    HandwavyEditEntry:
      type: object
      description: |
        Single in-place edit applied to a curated hand-wavy marker phrase.
        Records who made the change, when, and the before/after for whichever
        fields actually changed (`category`, `rationale`, and/or — Task
        #247 — the phrase string itself when a reviewer renamed it).
        Fields that did not change are omitted to keep the audit log
        compact.
      properties:
        editedBy:
          type: string
          description: Reviewer name or email that performed the edit. Absent when no reviewer was supplied.
        editedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the edit.
        category:
          type: object
          properties:
            from:
              $ref: "#/components/schemas/HandwavyCategory"
            to:
              $ref: "#/components/schemas/HandwavyCategory"
          required: [from, to]
        rationale:
          type: object
          description: Before/after values for the rationale text. Empty string indicates the rationale was set or cleared.
          properties:
            from:
              type: string
            to:
              type: string
        phrase:
          type: object
          description: |
            Task #247 — before/after values for the phrase string itself,
            present only when the reviewer renamed the marker via the
            edit endpoint's `newPhrase` field. Both values are stored
            already normalized (lowercase + collapsed whitespace).
          properties:
            from:
              type: string
            to:
              type: string
          required: [from, to]
      required: [editedAt]

    HandwavyMarker:
      type: object
      properties:
        phrase:
          type: string
        category:
          $ref: "#/components/schemas/HandwavyCategory"
        addedBy:
          type: string
          description: Reviewer name or email that added the phrase. Absent for curated defaults.
        addedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp the phrase was added. Absent for curated defaults.
        rationale:
          type: string
          description: Free-text justification supplied by the reviewer at add time.
        edits:
          type: array
          description: |
            Chronological log of in-place edits to this marker (most recent
            last). Each entry records who changed what, when. Bounded
            server-side per marker.
          items:
            $ref: "#/components/schemas/HandwavyEditEntry"
      required: [phrase, category]

    HandwavyBatchHistoryPhrase:
      type: object
      description: |
        Task #135 — one element of a batch-removal history entry's `phrases`
        array. Mirrors the audit metadata that a single-removal entry carries
        at its top level. Per-phrase `reinstated*` fields track partial
        reinstates so a reviewer can pull individual phrases back out of a
        batch removal.
      properties:
        phrase:
          type: string
        category:
          $ref: "#/components/schemas/HandwavyCategory"
        addedBy:
          type: string
        addedAt:
          type: string
          format: date-time
        rationale:
          type: string
        edits:
          type: array
          description: Preserved on remove so reinstating still shows the edit history.
          items:
            $ref: "#/components/schemas/HandwavyEditEntry"
        reinstated:
          type: boolean
        reinstatedBy:
          type: string
        reinstatedAt:
          type: string
          format: date-time
      required: [phrase, category]

    HandwavyHistoryEntry:
      type: object
      description: |
        Removed-phrase audit record used so reviewers can reinstate a phrase
        with original context. Single-removal entries populate `phrase` +
        `category`; Task #135 batch-removal entries leave those empty and
        instead populate `phrases` with the list of removed phrases.
      properties:
        phrase:
          type: string
          description: |
            The removed phrase (single-removal entries). Empty/omitted on
            Task #135 batch entries — see `phrases` instead.
        category:
          $ref: "#/components/schemas/HandwavyCategory"
        addedBy:
          type: string
        addedAt:
          type: string
          format: date-time
        rationale:
          type: string
        edits:
          type: array
          description: Task #120 — preserved on remove so reinstating still shows the edit history.
          items:
            $ref: "#/components/schemas/HandwavyEditEntry"
        removedBy:
          type: string
          description: Reviewer name or email that removed the phrase(s).
        removedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp the phrase(s) were removed.
        reinstated:
          type: boolean
          description: |
            Task #121 — true once a reviewer has reinstated this phrase
            straight from the history log via POST
            /feedback/calibration/handwavy-phrases/reinstate. The same row
            cannot be reinstated twice — if the phrase is removed again, a
            new history row is appended. For Task #135 batch entries this
            is the AGGREGATE flag: true once every inner phrase has been
            reinstated (see `phrases[].reinstated`).
        reinstatedBy:
          type: string
          description: Reviewer name or email that reinstated the phrase from this history entry.
        reinstatedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp the phrase was reinstated from this history entry.
        undone:
          type: boolean
          description: |
            Task #130 — true when this history row was produced by a reviewer
            undoing a brand-new add via POST
            /feedback/calibration/handwavy-phrases/undo. The UI renders these
            rows distinctly from manual removals so the audit trail clearly
            reads "added then undone" rather than "added then removed".
        undoneBy:
          type: string
          description: Reviewer name or email that pressed Undo on this entry.
        phrases:
          type: array
          description: |
            Task #135 — present when the entry represents a batch removal.
            Each item lists one removed phrase plus its original add-history
            metadata and per-phrase reinstate state.
          items:
            $ref: "#/components/schemas/HandwavyBatchHistoryPhrase"
      required: [removedAt]

    HandwavyPhraseRemovalBatchSummary:
      type: object
      description: |
        Task #160 — Slim summary of one BATCH removal entry from the hand-wavy
        phrase history log, scoped to the fields the reinstate-batch CLI
        picker needs to show (timestamp, reviewer, phrase count,
        already-reinstated flag, plus a small sample of removed phrases).
      properties:
        removedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the batch removal entry. Pass back to /reinstate-batch as `removedAt`.
        removedBy:
          type: string
          description: Reviewer name or email recorded on the batch removal. Omitted if the original removal didn't supply one.
        phraseCount:
          type: integer
          description: Number of phrases that were removed in this batch.
        reinstated:
          type: boolean
          description: Aggregate flag — true once every inner phrase from this batch has already been reinstated.
        samplePhrases:
          type: array
          maxItems: 5
          items:
            type: string
          description: First few removed phrases (capped at 5), shown in the picker so reviewers can identify the batch at a glance.
      required: [removedAt, phraseCount, reinstated, samplePhrases]

    HandwavyPhraseRemovalBatchesList:
      type: object
      description: |
        Task #160 — Response from GET
        /feedback/calibration/handwavy-phrases/removal-batches. Newest batches
        are returned first.
      properties:
        limit:
          type: integer
          description: Effective page size used for this response.
        totalBatches:
          type: integer
          description: Total number of batch removal entries in the history log (regardless of `limit`).
        batches:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseRemovalBatchSummary"
      required: [limit, totalBatches, batches]

    HandwavyPhraseRemovalBatchDetail:
      type: object
      description: |
        Task #176 — Detail response for one batch removal entry. Echoes the
        FULL inner phrase list with per-phrase audit metadata so the
        reinstate-batch CLI (and the Task #244 web reviewer picker) can
        render every phrase the action would touch — not just the
        5-phrase sample the list endpoint returns. `reinstatedCount`
        / `reinstated` together let callers spot a partial- or
        already-fully-reinstated batch before re-firing the action.
      properties:
        removedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the parent batch removal entry (echoes the path parameter).
        removedBy:
          type: string
          description: Reviewer name or email recorded on the batch removal. Omitted if the original removal didn't supply one.
        reinstated:
          type: boolean
          description: Aggregate flag — true once every inner phrase from this batch has been reinstated.
        reinstatedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp the batch was fully reinstated, when `reinstated` is true.
        reinstatedBy:
          type: string
          description: Reviewer name or email recorded on the batch-level reinstate, when `reinstated` is true.
        phraseCount:
          type: integer
          description: Total number of phrases in this batch.
        reinstatedCount:
          type: integer
          description: Number of inner phrases that have already been reinstated (per-phrase or via a prior batch reinstate).
        phrases:
          type: array
          description: Full inner phrase list with per-phrase audit metadata.
          items:
            $ref: "#/components/schemas/HandwavyBatchHistoryPhrase"
      required: [removedAt, reinstated, phraseCount, reinstatedCount, phrases]

    HandwavyPhraseRemovalBatchDetailError:
      type: object
      description: |
        Task #176 — 404 response shape for the batch detail endpoint.
        `reason` discriminates between "no matching removal-history entry"
        and "matched a single-phrase removal" so callers can format a
        targeted hint (e.g. the CLI suggests /reinstate for the latter).
      properties:
        error:
          type: string
        reason:
          type: string
          enum: [history-not-found, not-a-batch]
      required: [error, reason]

    HandwavyPhrasesList:
      type: object
      properties:
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
        total:
          type: integer
        history:
          type: array
          description: Removal audit log (most recent last). Bounded server-side.
          items:
            $ref: "#/components/schemas/HandwavyHistoryEntry"
      required: [phrases, total, history]

    HandwavyPhraseEditBody:
      type: object
      description: |
        Request body for the in-place edit endpoint. The `phrase` string
        identifies which curated marker to update; provide at least one of
        `category`, `rationale`, or `newPhrase` to actually change
        something. Send an empty `rationale` string to clear an existing
        rationale. Task #247 — `newPhrase` is the rename target; the edit
        only changes the row identity when its normalized form differs
        from the existing phrase, otherwise it is treated as a no-op.
      properties:
        phrase:
          type: string
          description: Existing curated phrase to edit (matched after lowercase + whitespace normalization).
        category:
          $ref: "#/components/schemas/HandwavyCategory"
        rationale:
          type: string
          description: New rationale text. Empty string clears the existing rationale.
        newPhrase:
          type: string
          description: |
            Task #247 — Rename target. Normalized to lowercase + collapsed
            whitespace before matching, and rejected when the normalized
            form already belongs to another active marker. When the
            normalized form matches the existing `phrase`, the rename is a
            no-op; the request can still apply concurrent
            `category`/`rationale` updates.
        reviewer:
          type: string
          description: Reviewer name or email recorded in the edit audit entry. Optional.
      required: [phrase]

    HandwavyPhraseBody:
      type: object
      properties:
        phrase:
          type: string
          description: Marker phrase (case-insensitive substring match against report text after whitespace collapsing).
        category:
          $ref: "#/components/schemas/HandwavyCategory"
        dryRun:
          type: boolean
          description: |
            Task #114 — when true on POST, the server does NOT persist the phrase. It runs the
            candidate phrase against the curated benchmark corpus (T1 LEGIT / T2 BORDERLINE
            / T3 SLOP / T4 HALLUCINATED fixtures) and returns a `dryRunMatches` block so
            reviewers can see how many GREEN / YELLOW reports the phrase would have hit
            before they confirm the add.

            Task #155 — when true on DELETE for a single phrase, the server does NOT
            mutate the active list, history, or cache and instead returns a
            `HandwavyPhraseSingleRemoveDryRunResponse` (the same corpus + production
            removal-impact summary as the batch dry-run, with `batch: false`).

            Defaults to false (write-through behavior).
        reviewer:
          type: string
          description: Reviewer name or email recorded in the audit trail. Optional.
        rationale:
          type: string
          description: Free-text justification recorded with the phrase. Only consulted on POST.
        productionScanLimit:
          type: integer
          minimum: 100
          maximum: 10000
          description: |
            Task #125 — optional override for the upper bound on the production-archive
            scan that backs the dry-run preview. Only consulted on the dry-run path
            (the only place the production scan runs). Heavy-user installs can widen
            the window for a stronger false-positive signal; small installs can tighten
            it to focus on recent reporter behavior. Defaults to 2000 when omitted so
            existing reviewers see no behavior change. The chosen value is echoed back
            in `dryRunMatchesProductionLimit` so the UI can label the second signal
            accurately.

            Task #230 — also accepted on the DELETE single-phrase dry-run path
            via `HandwavyPhraseBody`, which shares this body schema, so the
            same reviewer-chosen window drives the single-phrase removal preview
            too. The chosen value is echoed back in
            `HandwavyPhraseSingleRemoveDryRunResponse.dryRunImpact.productionLimit`.
      required: [phrase]

    HandwavyPhraseUndoBody:
      type: object
      description: |
        Task #130 — body for POST /feedback/calibration/handwavy-phrases/undo.
        The active marker is matched by `phrase` + `addedAt`; the request is
        rejected with 409 if the marker is older than the server-side undo
        window (default 5 minutes).
      properties:
        phrase:
          type: string
          description: The (already-normalized) phrase of the live marker to undo.
        addedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp from the live marker's `addedAt` field.
        reviewer:
          type: string
          description: Reviewer name or email recorded as `removedBy`/`undoneBy` on the resulting history row. Optional.
      required: [phrase, addedAt]

    HandwavyPhraseUndoBatchEntry:
      type: object
      description: |
        Task #233 — one `(phrase, addedAt)` pair to undo as part of a batch.
        Mirrors the single-undo body fields; see `HandwavyPhraseUndoBody`
        for the matching contract.
      properties:
        phrase:
          type: string
          description: The (already-normalized) phrase of the live marker to undo.
        addedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp from the live marker's `addedAt` field.
      required: [phrase, addedAt]

    HandwavyPhraseUndoBatchBody:
      type: object
      description: |
        Task #233 — body for POST /feedback/calibration/handwavy-phrases/undo-batch.
        The reviewer's UI walks every reviewer-added marker that's still
        inside its per-marker undo window and sends each one as a pair
        in `entries`. The server runs each entry through the per-marker
        undo path so every successful undo still produces its own
        `undone: true` history row (no batch-merge row that hides
        per-phrase provenance). Capped at 200 entries per call.
      properties:
        entries:
          type: array
          minItems: 1
          maxItems: 200
          items:
            $ref: "#/components/schemas/HandwavyPhraseUndoBatchEntry"
        reviewer:
          type: string
          description: Reviewer name or email recorded as `removedBy`/`undoneBy` on every successful undo's history row. Optional.
      required: [entries]

    HandwavyPhraseUndoBatchEntryResult:
      type: object
      description: |
        Task #233 — outcome of attempting to undo one `(phrase, addedAt)`
        pair as part of a /undo-batch round-trip. Mirrors the
        single-undo failure reasons so the UI can render the same per-row
        explanations the per-row Undo button already surfaces.
      properties:
        phrase:
          type: string
        undone:
          type: boolean
        reason:
          type: string
          enum: [not-found, addedAt-mismatch, no-addedAt, window-expired]
          description: Present only when `undone` is false.
        historyEntry:
          allOf:
            - $ref: "#/components/schemas/HandwavyHistoryEntry"
          description: Present only when `undone` is true. The fresh `undone:true` history row appended for this phrase.
      required: [phrase, undone]

    HandwavyPhraseUndoBatchResponse:
      type: object
      description: |
        Task #233 — response from POST /feedback/calibration/handwavy-phrases/undo-batch.
        `phrases` and `history` echo the post-batch active list and audit
        log so callers can refresh their cached views in one round-trip.
      properties:
        undone:
          type: boolean
          description: Always true on a successful response (the batch was processed).
        batch:
          type: boolean
          description: Always true — distinguishes this response from a single-phrase /undo.
        undoneCount:
          type: integer
          description: Number of entries actually undone in this round-trip.
        skipped:
          type: integer
          description: Number of entries skipped (any non-success per-entry reason).
        total:
          type: integer
          description: Number of active phrases after the batch.
        results:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseUndoBatchEntryResult"
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
        history:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyHistoryEntry"
      required:
        [undone, batch, undoneCount, skipped, total, results, phrases, history]

    HandwavyPhraseRevertEditBody:
      type: object
      description: |
        Task #132 — body for POST /feedback/calibration/handwavy-phrases/revert-edit.
        The edit entry is matched by `phrase` + `editedAt`.
      properties:
        phrase:
          type: string
          description: Existing curated phrase (matched after lowercase + whitespace normalization).
        editedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the `edits` entry to undo.
        reviewer:
          type: string
          description: Reviewer name or email recorded on the inverse edit entry. Optional.
      required: [phrase, editedAt]

    HandwavyPhraseBatchRemoveBody:
      type: object
      description: |
        Task #135 — batch removal request body for DELETE
        /feedback/calibration/handwavy-phrases. Sends up to 200 phrases in
        one round-trip; the server runs a single in-memory pass, a single
        file rewrite, and appends ONE history entry that lists every removed
        phrase. Mutually exclusive with the single-phrase `phrase` field.

        Task #145 — when `dryRun: true` the server returns a preview
        (`HandwavyPhraseBatchRemoveDryRunResponse`) that mirrors the
        post-mutation per-phrase results plus a corpus impact summary, but
        does NOT mutate the active list, the history log, or the cache.
      properties:
        phrases:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: string
          description: List of phrases to remove. Normalized server-side.
        reviewer:
          type: string
          description: Reviewer name or email recorded on the batch history entry. Optional.
        dryRun:
          type: boolean
          description: When true, return a removal preview (corpus impact + per-phrase outcomes) without mutating the active list.
        productionScanLimit:
          type: integer
          minimum: 100
          maximum: 10000
          description: |
            Task #229 / #230 — optional override for the upper bound on the
            production-archive scan that backs the bulk-removal dry-run. Mirrors the
            same field on `HandwavyPhraseBody` (Task #125) so the reviewer-chosen scan
            window persisted in the calibration UI applies to every production-archive
            scan, not just the add-phrase preview. Only consulted on the dry-run path,
            but validated on every DELETE so a malformed value never silently slips into
            a real bulk delete. Heavy-user installs can widen the window for a sharper
            "valid detections lost" signal; small installs can tighten it. Defaults to
            2000 when omitted so existing reviewers see no behavior change. The chosen
            value is echoed back in
            `HandwavyPhraseBatchRemoveDryRunResponse.dryRunImpact.productionLimit`.
      required: [phrases]

    HandwavyPhraseBatchRemoveDryRunImpact:
      type: object
      description: |
        Task #145 — projected impact of a bulk removal against a single
        corpus (curated benchmark fixtures or recent production reports).
        `total` counts fixtures that are flagged today but would NOT be
        flagged after the removal, broken down by tier. `validDetectionsLost`
        is `byTier.t3Slop + byTier.t4Hallucinated` (the worrying number).
        `falsePositivesDropped` is `byTier.t1Legit + byTier.t2Borderline`
        (informational good news). `warning` is set when valid detections
        would be lost; null otherwise.
      properties:
        total:
          type: integer
        byTier:
          type: object
          properties:
            t1Legit: { type: integer }
            t2Borderline: { type: integer }
            t3Slop: { type: integer }
            t4Hallucinated: { type: integer }
          required: [t1Legit, t2Borderline, t3Slop, t4Hallucinated]
        validDetectionsLost:
          type: integer
        falsePositivesDropped:
          type: integer
        corpusSize:
          type: integer
        sampleMatches:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              tier:
                type: string
                enum: [T1_LEGIT, T2_BORDERLINE, T3_SLOP, T4_HALLUCINATED]
              snippet:
                nullable: true
                description: |
                  Task #345 — A short context snippet (~80 chars centered on the
                  matched phrase) cut from the row's original text so a reviewer
                  can judge the un-flag in place without opening /verify/:id.
                  Returned as a structured `{ before, match, after }` triple so
                  the UI can render the matched phrase highlighted while the
                  rest of the snippet stays plain text. All three fields are
                  raw text — the client is responsible for escaping them when
                  rendering (React's text-node rendering already does this).
                  Null when the matched phrase could not be located in the
                  original text (defensive fallback; should not happen for the
                  flow that produced the match).
                type: object
                properties:
                  before: { type: string }
                  match: { type: string }
                  after: { type: string }
                required: [before, match, after]
            required: [id, tier]
        warning:
          oneOf:
            - type: string
            - type: "null"
        oldestCreatedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Task #218 — For production scans, the earliest `createdAt` among the rows
            actually included in the scanned sample (i.e. survived the label/content
            filter, so the same population reflected in `corpusSize`). Reported so
            reviewers using the bulk-retire flow can tell whether the impact signal
            reflects current reporter behavior or a long-stale archive (e.g. "scanned
            2000 reports from 2026-02-01 to 2026-04-22"). Null on the curated
            benchmark block (fixtures have no wall-clock timestamp) and when the
            scan was empty. Mirrors the add-time `HandwavyPhraseDryRunMatches`
            field of the same name (Task #124).
        newestCreatedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Task #218 — For production scans, the most recent `createdAt` among the
            rows actually included in the scanned sample. See `oldestCreatedAt` for
            details. Null on the curated benchmark block and when the scan was empty.
        archiveTotal:
          type: integer
          nullable: true
          description: |
            Task #323 — For production scans, the total number of label-bearing
            archived reports (i.e. the addressable population the chosen scan
            window is applied to, NOT just the `corpusSize` returned by the
            tier-filter). Reported alongside the response's `productionLimit`
            so the bulk-removal preview can warn when a reviewer-tightened
            scan window covers only a small slice of the archive (e.g.
            "scanning 100 of ~8,400 archived reports — recent reporter
            behavior only"). Null on the curated benchmark block (the corpus
            is the full fixture set, not a sample) and when the production
            scan was skipped because nothing would be removed.
      required:
        [
          total,
          byTier,
          validDetectionsLost,
          falsePositivesDropped,
          corpusSize,
          sampleMatches,
          warning,
          oldestCreatedAt,
          newestCreatedAt,
          archiveTotal,
        ]

    HandwavyPhraseBatchRemoveDryRunResponse:
      type: object
      description: |
        Task #145 — preview response returned when DELETE
        /feedback/calibration/handwavy-phrases is called with `dryRun: true`
        on the batch path. Mirrors the post-mutation per-phrase result
        shape but does NOT mutate the active list, history, or cache.
      properties:
        dryRun:
          type: boolean
          enum: [true]
        batch:
          type: boolean
          enum: [true]
        wouldRemove:
          type: integer
          description: Count of phrases that WOULD be removed if the batch were applied.
        notFound:
          type: integer
        duplicateInBatch:
          type: integer
        total:
          type: integer
          description: Active list size BEFORE the batch (no mutation occurred).
        projectedTotal:
          type: integer
          description: Projected active list size AFTER the batch would be applied.
        results:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseBatchRemoveResultEntry"
        dryRunImpact:
          type: object
          properties:
            corpus:
              $ref: "#/components/schemas/HandwavyPhraseBatchRemoveDryRunImpact"
            production:
              oneOf:
                - $ref: "#/components/schemas/HandwavyPhraseBatchRemoveDryRunImpact"
                - type: "null"
            productionError:
              oneOf:
                - type: string
                - type: "null"
            productionLimit:
              type: integer
          required: [corpus, productionError, productionLimit]
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
          description: Active list (unchanged because this is a dry run).
      required:
        [
          dryRun,
          batch,
          wouldRemove,
          notFound,
          duplicateInBatch,
          total,
          projectedTotal,
          results,
          dryRunImpact,
          phrases,
        ]

    HandwavyPhraseSingleRemoveDryRunResponse:
      type: object
      description: |
        Task #155 — preview response returned when DELETE
        /feedback/calibration/handwavy-phrases is called with `dryRun: true`
        on the SINGLE-phrase path. Mirrors the batch dry-run shape (with
        `batch: false`) so the in-UI Trash flow can show the same corpus +
        production removal-impact warning before a one-click removal,
        without mutating the active list, history, or cache.
      properties:
        dryRun:
          type: boolean
          enum: [true]
        batch:
          type: boolean
          enum: [false]
        wouldRemove:
          type: integer
          description: 0 if the phrase is not on the active list, 1 otherwise.
        notFound:
          type: integer
        duplicateInBatch:
          type: integer
        phrase:
          type: string
          description: Normalized form actually compared against the active list.
        raw:
          type: string
          description: Original (raw) phrase as supplied by the client.
        removed:
          type: boolean
          description: Whether the phrase WOULD be removed if the call were not a dry run.
        reason:
          oneOf:
            - type: string
              enum: [not-found, duplicate-in-batch]
            - type: "null"
        total:
          type: integer
          description: Active list size BEFORE the removal (no mutation occurred).
        projectedTotal:
          type: integer
          description: Projected active list size AFTER the removal would be applied.
        results:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseBatchRemoveResultEntry"
          description: Single-element array mirroring the batch dry-run results shape.
        dryRunImpact:
          type: object
          properties:
            corpus:
              $ref: "#/components/schemas/HandwavyPhraseBatchRemoveDryRunImpact"
            production:
              oneOf:
                - $ref: "#/components/schemas/HandwavyPhraseBatchRemoveDryRunImpact"
                - type: "null"
            productionError:
              oneOf:
                - type: string
                - type: "null"
            productionLimit:
              type: integer
          required: [corpus, productionError, productionLimit]
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
          description: Active list (unchanged because this is a dry run).
      required:
        [
          dryRun,
          batch,
          wouldRemove,
          notFound,
          duplicateInBatch,
          phrase,
          raw,
          removed,
          total,
          projectedTotal,
          results,
          dryRunImpact,
          phrases,
        ]

    HandwavyPhraseBatchRemoveResultEntry:
      type: object
      description: |
        Per-phrase outcome from a Task #135 batch removal. `phrase` is the
        normalized form actually compared against the active list. `reason`
        is omitted on successful removals.
      properties:
        raw:
          type: string
          description: Original (raw) phrase as supplied by the client.
        phrase:
          type: string
          description: Normalized form actually compared against the active list.
        removed:
          type: boolean
        reason:
          type: string
          enum: [not-found, duplicate-in-batch]
      required: [raw, phrase, removed]

    HandwavyPhraseBatchRemoveResponse:
      type: object
      description: |
        Task #135 — response from a batch DELETE call. The shape is
        intentionally distinct from `HandwavyPhraseMutationResponse` so
        clients can disambiguate by checking `batch === true`.
      properties:
        batch:
          type: boolean
          enum: [true]
        removed:
          type: integer
          description: Count of phrases that actually came off the active list.
        notFound:
          type: integer
          description: Count of phrases that were not in the active list at lookup time.
        total:
          type: integer
          description: Number of active phrases AFTER the batch.
        results:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseBatchRemoveResultEntry"
        historyEntry:
          oneOf:
            - $ref: "#/components/schemas/HandwavyHistoryEntry"
            - type: "null"
          description: The single batch history entry, or null if nothing was removed.
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
          description: Full active list after the batch.
        history:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyHistoryEntry"
          description: Full removal audit log after the batch.
      required: [batch, removed, notFound, total, results, phrases, history]

    HandwavyPhraseReinstateBody:
      type: object
      description: |
        Task #121 — body for POST /feedback/calibration/handwavy-phrases/reinstate.
        The history row is matched by `phrase` + `removedAt`.
      properties:
        phrase:
          type: string
          description: The (already-normalized) phrase from the matching history entry.
        removedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the matching history entry's `removedAt` field.
        reviewer:
          type: string
          description: Reviewer name or email recorded as `addedBy`/`reinstatedBy` in the audit trail. Optional.
      required: [phrase, removedAt]

    HandwavyPhraseReinstateBatchBody:
      type: object
      description: |
        Task #144 — body for POST /feedback/calibration/handwavy-phrases/reinstate-batch.
        The batch history entry is matched by `removedAt`; every inner phrase
        that has not already been reinstated and is not currently active is
        re-added in one round-trip.

        Task #159 — when `dryRun: true` the server returns
        `HandwavyPhraseReinstateBatchDryRunResponse` with the same per-phrase
        outcome shape, and does NOT mutate the active marker list, the
        removal-history log, or the cache. Both the mutating and dry-run
        paths return HTTP 200; callers discriminate on the `dryRun` flag
        in the response body.

        Task #360 — when `phrases` is provided the server only considers
        inner phrases of the matched batch whose phrase appears in the
        allow-list (normalized: lowercase + collapsed whitespace). Inner
        phrases NOT in the list are omitted from `results` entirely and
        stay on the removal-history list as removed. A PROVIDED list —
        including `[]` — is treated as an explicit allow-list, so an
        empty array reinstates nothing. Allow-list values that don't
        match any inner phrase of the batch are reported as
        `not-in-batch` skip results.
      properties:
        removedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the matching batch removal entry's `removedAt` field.
        reviewer:
          type: string
          description: Reviewer name or email recorded as `addedBy`/`reinstatedBy` on every reinstated phrase. Optional.
        dryRun:
          type: boolean
          description: When true, return a per-phrase reinstate preview without mutating the active list or removal-history log.
        phrases:
          type: array
          description: |
            Task #360 — optional allow-list of inner phrases to reinstate
            from the matched batch (normalized: lowercase + collapsed
            whitespace). When omitted, every not-yet-reinstated inner
            phrase is processed (legacy behaviour). When present, only
            inner phrases whose normalized value appears in this list
            are considered; the rest stay on the removal-history list as
            removed. An empty array is therefore a no-op. Allow-list
            values that don't match any inner phrase of the batch are
            reported as `not-in-batch` in the per-phrase results.
          items:
            type: string
      required: [removedAt]

    HandwavyPhraseReinstateBatchResponse:
      type: object
      description: |
        Task #144 — response from POST /feedback/calibration/handwavy-phrases/reinstate-batch.
      properties:
        reinstated:
          type: boolean
          description: Always true on a successful mutating response (the batch was processed).
        batch:
          type: boolean
          description: Always true — distinguishes this response from a single-phrase /reinstate.
        removedAt:
          type: string
          format: date-time
        reinstatedCount:
          type: integer
          description: Number of inner phrases actually re-added in this round-trip.
        skipped:
          type: integer
          description: Number of inner phrases skipped (already reinstated or already active).
        total:
          type: integer
          description: Number of active phrases after the batch reinstate.
        results:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseReinstateBatchEntryResult"
        historyEntry:
          $ref: "#/components/schemas/HandwavyHistoryEntry"
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
        history:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyHistoryEntry"
      required:
        [
          reinstated,
          batch,
          removedAt,
          reinstatedCount,
          skipped,
          total,
          results,
          historyEntry,
          phrases,
          history,
        ]

    HandwavyPhraseReinstateBatchEntryResult:
      type: object
      description: |
        Task #144 — outcome of attempting to reinstate one inner phrase as
        part of a /reinstate-batch round-trip.
      properties:
        phrase:
          type: string
        reinstated:
          type: boolean
        reason:
          type: string
          enum: [already-reinstated, already-active, not-in-batch]
          description: |
            Present only when `reinstated` is false. `not-in-batch` is only
            returned by Task #360 allow-list calls when a caller-supplied
            phrase is not part of the matched batch.
      required: [phrase, reinstated]

    HandwavyPhraseReinstateBatchDryRunResponse:
      type: object
      description: |
        Task #159 — dry-run preview returned when POST
        /feedback/calibration/handwavy-phrases/reinstate-batch is called with
        `dryRun: true`. Mirrors the per-phrase outcome shape of
        `HandwavyPhraseReinstateBatchResponse` so callers can reuse the same
        renderer, but does NOT mutate the active marker list, the
        removal-history log, or the cache. Both the mutating and dry-run
        paths return HTTP 200; callers discriminate on the `dryRun` flag
        in the response body.
      properties:
        dryRun:
          type: boolean
          enum: [true]
        batch:
          type: boolean
          enum: [true]
        removedAt:
          type: string
          format: date-time
        reinstatedCount:
          type: integer
          description: Number of inner phrases that WOULD be re-added if the batch were applied.
        skipped:
          type: integer
          description: Number of inner phrases that would be skipped (already reinstated or already active).
        total:
          type: integer
          description: Projected number of active phrases after the batch reinstate would be applied.
        results:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyPhraseReinstateBatchEntryResult"
        historyEntry:
          $ref: "#/components/schemas/HandwavyHistoryEntry"
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
          description: Active list (unchanged because this is a dry run).
        history:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyHistoryEntry"
          description: Removal-history log (unchanged because this is a dry run).
      required:
        [
          dryRun,
          batch,
          removedAt,
          reinstatedCount,
          skipped,
          total,
          results,
          historyEntry,
          phrases,
          history,
        ]

    HandwavyPhraseDryRunMatches:
      type: object
      description: |
        Task #114 — preview of how a candidate FLAT hand-wavy phrase would have flagged
        the curated benchmark corpus. `falsePositives` is the count of T1 LEGIT (GREEN)
        and T2 BORDERLINE (YELLOW) fixtures the substring would have matched — a high
        value is a strong signal that the phrase will crater AVRI for legitimate reports.
      properties:
        total:
          type: integer
          description: Total number of corpus fixtures the candidate phrase matched.
        byTier:
          type: object
          properties:
            t1Legit:
              type: integer
              description: Matches in T1 LEGIT fixtures (GREEN — legitimate, well-evidenced).
            t2Borderline:
              type: integer
              description: Matches in T2 BORDERLINE fixtures (YELLOW — debatable signal).
            t3Slop:
              type: integer
              description: Matches in T3 SLOP fixtures (RED — known slop archetypes).
            t4Hallucinated:
              type: integer
              description: Matches in T4 HALLUCINATED fixtures (RED — fabricated/hallucinated).
          required: [t1Legit, t2Borderline, t3Slop, t4Hallucinated]
        falsePositives:
          type: integer
          description: T1 + T2 hits — the count of GREEN/YELLOW corpus reports that would have been flagged.
        corpusSize:
          type: integer
          description: Total number of corpus fixtures evaluated.
        sampleMatches:
          type: array
          maxItems: 12
          items:
            type: object
            properties:
              id:
                type: string
              tier:
                type: string
                enum: [T1_LEGIT, T2_BORDERLINE, T3_SLOP, T4_HALLUCINATED]
              snippet:
                nullable: true
                description: |
                  Task #495 — A short context snippet (~80 chars centered on the
                  matched phrase) cut from the row's original text so a reviewer
                  evaluating a NEW candidate phrase can judge each sample match
                  in place without opening /verify/:id (or, for curated cohorts,
                  cross-referencing the fixture file). Returned as a structured
                  `{ before, match, after }` triple so the UI can highlight the
                  matched phrase while the rest stays plain text. All three
                  fields are raw text — the client is responsible for escaping
                  them when rendering (React's text-node rendering already does
                  this). Null when the matched phrase could not be located in
                  the original text (defensive fallback; should not happen for
                  the flow that produced the match). Mirrors the per-row
                  removal preview's `snippet` field added in Task #345.
                type: object
                properties:
                  before: { type: string }
                  match: { type: string }
                  after: { type: string }
                required: [before, match, after]
            required: [id, tier, snippet]
          description: Up to 12 sample matched fixtures (id + tier + nullable snippet) for reviewer review.
        warning:
          type: string
          nullable: true
          description: |
            Reviewer-facing warning string when the phrase would flag legitimate reports
            (`falsePositives > 0`). Null when there are no GREEN/YELLOW hits.
        oldestCreatedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Task #124 — For production scans, the earliest `createdAt` among the rows
            actually included in the scanned sample (i.e. survived the label/content
            filter, so the same population reflected in `corpusSize`). Reported so
            reviewers can tell whether the false-positive signal reflects current
            reporter behavior or a long-stale archive (e.g. "scanned 2000 reports
            from 2026-02-01 to 2026-04-22"). Null on the curated benchmark block
            (fixtures have no wall-clock timestamp) and when the scan was empty.
        newestCreatedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Task #124 — For production scans, the most recent `createdAt` among the
            rows actually included in the scanned sample. See `oldestCreatedAt` for
            details. Null on the curated benchmark block and when the scan was empty.
      required:
        [
          total,
          byTier,
          falsePositives,
          corpusSize,
          sampleMatches,
          oldestCreatedAt,
          newestCreatedAt,
        ]

    HandwavyPhraseDryRunOverlaps:
      type: object
      description: |
        Task #123 — overlap signal between a candidate FLAT hand-wavy phrase and the
        already-curated active list. A non-empty `matches` array means adding the
        candidate would be redundant (or partially redundant) with an existing entry,
        which the reviewer should see BEFORE confirming the add. The relation tells
        the reviewer whether the candidate is an exact duplicate, a broader phrase
        that would supersede an existing one, or a narrower phrase already covered
        by an existing entry.
      properties:
        total:
          type: integer
          description: Number of curated entries the candidate overlaps with.
        matches:
          type: array
          items:
            type: object
            properties:
              phrase:
                type: string
                description: The existing curated phrase that the candidate overlaps with.
              category:
                $ref: "#/components/schemas/HandwavyCategory"
              relation:
                type: string
                enum:
                  [
                    equal,
                    candidate-contains-existing,
                    existing-contains-candidate,
                  ]
                description: |
                  How the candidate relates to the existing curated phrase:
                  - `equal`: identical normalized text (exact duplicate).
                  - `candidate-contains-existing`: candidate is broader; it would
                    supersede the existing entry (anything matching the existing
                    phrase would also match the candidate).
                  - `existing-contains-candidate`: candidate is narrower; the
                    existing phrase already covers everything the candidate would.
            required: [phrase, category, relation]
      required: [total, matches]

    HandwavyPhraseMutationResponse:
      type: object
      properties:
        added:
          type: boolean
          description: True when POST appended the phrase. Omitted on PATCH/DELETE responses.
        edited:
          type: boolean
          description: True when PATCH actually changed the marker; false when the supplied updates matched the existing values. Omitted on POST/DELETE responses.
        editEntry:
          $ref: "#/components/schemas/HandwavyEditEntry"
        removed:
          type: boolean
          description: True when DELETE removed the phrase. Omitted on POST responses.
        reinstated:
          type: boolean
          description: |
            Task #121 — true when the response is from POST
            /feedback/calibration/handwavy-phrases/reinstate. Omitted on plain
            POST/DELETE responses.
        undone:
          type: boolean
          description: |
            Task #130 — true when the response is from POST
            /feedback/calibration/handwavy-phrases/undo. Omitted on plain
            POST/DELETE/reinstate responses.
        reverted:
          type: boolean
          description: |
            Task #132 — true when the response is from POST
            /feedback/calibration/handwavy-phrases/revert-edit. Inspect
            `edited` to tell whether the revert actually changed anything.
            Omitted on other endpoints.
        revertedEntry:
          $ref: "#/components/schemas/HandwavyEditEntry"
        phrase:
          type: string
          description: The normalized phrase that was added/removed.
        category:
          $ref: "#/components/schemas/HandwavyCategory"
        total:
          type: integer
          description: Number of active phrases after the mutation.
        marker:
          $ref: "#/components/schemas/HandwavyMarker"
        historyEntry:
          $ref: "#/components/schemas/HandwavyHistoryEntry"
        phrases:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyMarker"
          description: Full active list after the mutation.
        dryRun:
          type: boolean
          description: |
            Task #114 — true when the response is a preview only and the phrase was NOT
            persisted. When dryRun is true, `dryRunMatches` is populated and the existing
            active phrase list (unchanged) is returned in `phrases`.
        dryRunMatches:
          $ref: "#/components/schemas/HandwavyPhraseDryRunMatches"
        history:
          type: array
          items:
            $ref: "#/components/schemas/HandwavyHistoryEntry"
          description: Full removal audit log after the mutation (only included on DELETE).
        dryRunMatchesProduction:
          allOf:
            - $ref: "#/components/schemas/HandwavyPhraseDryRunMatches"
          nullable: true
          description: |
            Task #119 — Same shape as `dryRunMatches`, but scored against the most
            recent N production reports (capped by `dryRunMatchesProductionLimit`)
            instead of the curated benchmark cohorts. Production rows are bucketed
            into the same T1/T2/T3/T4 tiers as the curated corpus by mapping the
            persisted vulnrap composite label (STRONG/PROMISING -> T1, REASONABLE/
            NEEDS REVIEW -> T2, LIKELY INVALID -> T3, HIGH RISK -> T4). `corpusSize`
            on this block is the number of production reports actually scanned
            after dropping rows with no usable label / content. `null` when the
            production scan failed (see `dryRunMatchesProductionError`).
        dryRunMatchesProductionError:
          type: string
          nullable: true
          description: |
            Task #119 — Reviewer-facing notice when the production-archive scan
            could not run (e.g. DB unavailable). Null on success. The curated
            `dryRunMatches` block is still returned regardless.
        dryRunMatchesProductionLimit:
          type: integer
          description: |
            Task #119 — Upper bound on the number of most-recent production reports
            the dry-run scan considered. Reported so reviewers know the depth of
            the second signal (e.g. "scanned the last 2000 reports").
        dryRunOverlaps:
          $ref: "#/components/schemas/HandwavyPhraseDryRunOverlaps"
      required: [phrase, total, phrases]

    VisitRecorded:
      type: object
      properties:
        recorded:
          type: boolean
      required: [recorded]

    VisitorStats:
      type: object
      properties:
        totalUniqueVisitors:
          type: integer
        totalVisits:
          type: integer
      required: [totalUniqueVisitors, totalVisits]

    TrendsDailyTiers:
      type: object
      properties:
        clean:
          type: integer
        likelyHuman:
          type: integer
        questionable:
          type: integer
        likelySlop:
          type: integer
        slop:
          type: integer
      required: [clean, likelyHuman, questionable, likelySlop, slop]

    TrendsDailyReport:
      type: object
      properties:
        date:
          type: string
        count:
          type: integer
        avgScore:
          type: number
        tiers:
          $ref: "#/components/schemas/TrendsDailyTiers"
      required: [date, count, avgScore, tiers]

    TrendsFeedbackItem:
      type: object
      properties:
        date:
          type: string
        count:
          type: integer
        avgRating:
          type: number
        agreementRate:
          type: integer
      required: [date, count, avgRating, agreementRate]

    LatencyHistogramBin:
      type: object
      properties:
        ltMs:
          type: integer
          description: Upper bound (exclusive) of this bin in milliseconds. The last bin is open-ended (count of samples >= the previous bin's ltMs); ltMs is set to its lower bound for charting.
        count:
          type: integer
      required: [ltMs, count]

    LatencyPercentiles:
      type: object
      properties:
        p50:
          type: number
        p95:
          type: number
        p99:
          type: number
        sampleCount:
          type: integer
      required: [p50, p95, p99, sampleCount]

    LatencyEngineEntry:
      type: object
      properties:
        engine:
          type: string
        percentiles:
          $ref: "#/components/schemas/LatencyPercentiles"
        bins:
          type: array
          items:
            $ref: "#/components/schemas/LatencyHistogramBin"
      required: [engine, percentiles, bins]

    LatencyWorstEngine:
      type: object
      properties:
        engine:
          type: string
        p95:
          type: number
        pipelineP95:
          type: number
        ratio:
          type: number
          description: engine p95 divided by the median engine p95
      required: [engine, p95, pipelineP95, ratio]

    LatencySnapshot:
      type: object
      properties:
        windowHours:
          type: integer
        generatedAt:
          type: string
          format: date-time
        sampleCount:
          type: integer
        pipeline:
          type: object
          properties:
            percentiles:
              $ref: "#/components/schemas/LatencyPercentiles"
            bins:
              type: array
              items:
                $ref: "#/components/schemas/LatencyHistogramBin"
          required: [percentiles, bins]
        engines:
          type: array
          items:
            $ref: "#/components/schemas/LatencyEngineEntry"
        worstEngine:
          oneOf:
            - $ref: "#/components/schemas/LatencyWorstEngine"
            - type: "null"
      required:
        [windowHours, generatedAt, sampleCount, pipeline, engines, worstEngine]

    LatencyHistory:
      type: object
      properties:
        days:
          type: integer
        generatedAt:
          type: string
          format: date-time
        daily:
          type: array
          items:
            $ref: "#/components/schemas/LatencyHistoryDay"
      required: [days, generatedAt, daily]

    LatencyHistoryDay:
      type: object
      properties:
        date:
          type: string
          description: ISO date string (YYYY-MM-DD)
        sampleCount:
          type: integer
        pipeline:
          $ref: "#/components/schemas/LatencyPercentiles"
        engines:
          type: array
          items:
            $ref: "#/components/schemas/LatencyHistoryEngine"
      required: [date, sampleCount, pipeline, engines]

    LatencyHistoryEngine:
      type: object
      properties:
        engine:
          type: string
        percentiles:
          $ref: "#/components/schemas/LatencyPercentiles"
      required: [engine, percentiles]

    PublicStatusEngine:
      type: object
      description: |
        Health snapshot for one scoring subsystem. `status` is derived from
        when the corresponding pipeline stage was last seen successfully —
        operational (≤1h), degraded (≤6h), down (older than 6h but seen in
        the last 30 days), unknown (never observed).
      properties:
        id:
          type: string
          description: Stable machine id (linguistic, substance, cwe, avri, llm_gate).
        label:
          type: string
          description: Human-readable engine name.
        status:
          type: string
          enum: [operational, degraded, down, unknown]
        recentSampleCount:
          type: integer
          description: Number of traces in the last hour that included this stage.
        lastSeenAt:
          oneOf:
            - type: "null"
            - type: string
              format: date-time
          description: Most recent trace timestamp that included this stage, or null if never seen.
      required: [id, label, status, recentSampleCount, lastSeenAt]

    PublicStatusUptime:
      type: object
      properties:
        windowDays:
          type: integer
        daysWithTraffic:
          type: integer
        uptimePercentage:
          type: number
          description: Fraction of windowDays with at least one successful trace, 0–100.
      required: [windowDays, daysWithTraffic, uptimePercentage]

    PublicStatusLatency:
      type: object
      properties:
        windowHours:
          type: integer
        sampleCount:
          type: integer
        p50Ms:
          type: number
        p95Ms:
          type: number
      required: [windowHours, sampleCount, p50Ms, p95Ms]

    PublicStatusSnapshot:
      type: object
      properties:
        generatedAt:
          type: string
          format: date-time
        overallStatus:
          type: string
          enum: [operational, degraded, down]
        uptime:
          $ref: "#/components/schemas/PublicStatusUptime"
        latency:
          $ref: "#/components/schemas/PublicStatusLatency"
        engines:
          type: array
          items:
            $ref: "#/components/schemas/PublicStatusEngine"
      required: [generatedAt, overallStatus, uptime, latency, engines]

    PublicStatusIncident:
      type: object
      description: |
        A single detected incident — a contiguous window where one or more
        engine pipeline stages were missing for longer than one hour.
      properties:
        id:
          type: string
          description: Stable identifier for this incident (hash of startedAt + engines).
        severity:
          type: string
          enum: [degraded, outage]
          description: degraded if some engines were affected, outage if all engines were affected.
        startedAt:
          type: string
          format: date-time
        endedAt:
          oneOf:
            - type: "null"
            - type: string
              format: date-time
          description: Recovery timestamp, or null if the incident is still ongoing.
        durationMs:
          oneOf:
            - type: "null"
            - type: number
          description: Duration in milliseconds, or null if still ongoing.
        affectedEngines:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              label:
                type: string
            required: [id, label]
      required: [id, severity, startedAt, endedAt, durationMs, affectedEngines]

    PublicStatusIncidentsResponse:
      type: object
      properties:
        generatedAt:
          type: string
          format: date-time
        windowDays:
          type: integer
        incidents:
          type: array
          items:
            $ref: "#/components/schemas/PublicStatusIncident"
      required: [generatedAt, windowDays, incidents]

    TrendsData:
      type: object
      properties:
        days:
          type: integer
        totalReports:
          type: integer
        totalFeedback:
          type: integer
        dailyReports:
          type: array
          items:
            $ref: "#/components/schemas/TrendsDailyReport"
        feedbackTrend:
          type: array
          items:
            $ref: "#/components/schemas/TrendsFeedbackItem"
      required: [days, totalReports, totalFeedback, dailyReports, feedbackTrend]

    PresetEngineWeights:
      type: object
      description: |
        Per-engine emphasis weights in the [0, 2] range. 1.0 means "use the
        platform default weighting"; lower values de-emphasise that engine,
        higher values emphasise it. Applied as multipliers to each engine's
        breakdown score on /check before the Bayesian probability combination.
      properties:
        linguistic:
          type: number
          minimum: 0
          maximum: 2
        factual:
          type: number
          minimum: 0
          maximum: 2
        template:
          type: number
          minimum: 0
          maximum: 2
        llm:
          type: number
          minimum: 0
          maximum: 2
      required: [linguistic, factual, template, llm]

    PresetEntry:
      type: object
      description: A single curated preset profile.
      properties:
        id:
          type: string
          description: Opaque slug used in `/check?preset=<id>` deep-links.
        name:
          type: string
        description:
          type: string
        audience:
          type: string
          description: Who this preset is calibrated for (free text).
        sensitivity:
          type: string
          enum: [lenient, balanced, strict]
        slopThresholdLow:
          type: integer
          minimum: 0
          maximum: 100
        slopThresholdHigh:
          type: integer
          minimum: 0
          maximum: 100
        engineWeights:
          $ref: "#/components/schemas/PresetEngineWeights"
      required:
        - id
        - name
        - description
        - audience
        - sensitivity
        - slopThresholdLow
        - slopThresholdHigh
        - engineWeights

    RoadmapItem:
      type: object
      description: A single public roadmap entry shown on `/roadmap`.
      properties:
        id:
          type: string
          description: Opaque slug for the roadmap item (stable across curator edits).
        column:
          type: string
          enum: [now, next, later, shipped]
          description: Which roadmap column the card renders in.
        status:
          type: string
          enum: [in_progress, shipping_soon, planned, research, shipped]
          description: Status badge shown on the card.
        title:
          type: string
          description: Card title.
        description:
          type: string
          description: One-line description of the item.
        eta:
          type: string
          description: Optional fuzzy ETA (e.g. "Q2 2026"). Illustrative only.
        shippedAt:
          type: string
          description: ISO date when the item shipped (e.g. "2026-05-03"). Present only for shipped items.
        changelogUrl:
          type: string
          description: Relative URL to the changelog entry or release notes for this shipped item (e.g. "/changelog#v3.12.0").
      required: [id, column, status, title, description]

    RoadmapResponse:
      type: object
      description: |
        Public roadmap returned by `GET /api/roadmap`. The `version`
        string lets clients cache-bust when curators ship a new
        revision of `roadmap.json`. `updatedAt` is a free-form ISO
        date string surfaced in the page footer. `shipped` carries
        items delivered in the last 90 days.
      properties:
        version:
          type: string
        updatedAt:
          type: string
        items:
          type: array
          items:
            $ref: "#/components/schemas/RoadmapItem"
        shipped:
          type: array
          items:
            $ref: "#/components/schemas/RoadmapItem"
      required: [version, items]

    GallerySample:
      type: object
      description: A single curated sample report shown on `/gallery`.
      properties:
        id:
          type: string
          description: Opaque slug for the gallery entry (stable across curator edits).
        label:
          type: string
          enum: [obvious_slop, subtle_slop, borderline, clean]
          description: Curator-assigned bucket the sample illustrates.
        title:
          type: string
          description: Card title.
        snippet:
          type: string
          description: ~150 char preview of the report content.
        score:
          type: integer
          minimum: 0
          maximum: 100
          description: Final composite slop score for the sample (0 clean → 100 slop).
        topSignals:
          type: array
          minItems: 1
          maxItems: 3
          items:
            type: string
          description: Top fired signals, in display order.
        reportId:
          type: integer
          minimum: 1
          description: Seed report id; click-through opens `/results/<reportId>`.
      required: [id, label, title, snippet, score, topSignals, reportId]

    IncidentEntry:
      type: object
      description: |
        Task #707 — One public incident postmortem. Severity uses a
        plain four-tier scale; `changelogAnchor` (when present) is the
        anchor fragment of the related `/changelog#anchor` entry so the
        UI can link the remediation to the shipped release notes.
      properties:
        id:
          type: string
          description: Opaque slug for the incident (stable across curator edits).
        date:
          type: string
          format: date
          description: ISO date the incident started (YYYY-MM-DD).
        duration:
          type: string
          description: Human-readable incident duration (e.g. "42 minutes", "3 hours").
        severity:
          type: string
          enum: [low, medium, high, critical]
          description: Curator-assigned severity tier.
        summary:
          type: string
          description: One-sentence description of what happened.
        rootCause:
          type: string
          description: Plain-language root cause analysis.
        remediation:
          type: string
          description: What we changed to prevent recurrence.
        changelogAnchor:
          type: string
          description: Optional `/changelog#anchor` fragment linking to the related release notes.
      required: [id, date, duration, severity, summary, rootCause, remediation]

    IncidentLog:
      type: object
      description: |
        Public incident log returned by `GET /api/incidents`. The
        `version` string lets clients cache-bust when curators ship a
        new revision of `incidents.json`. `incidents` is empty when no
        public incidents have been recorded.
      properties:
        version:
          type: string
        incidents:
          type: array
          items:
            $ref: "#/components/schemas/IncidentEntry"
      required: [version, incidents]

    CreateIncidentBody:
      type: object
      description: |
        Task #1057 — Request body for `POST /api/incidents`. All
        fields from `IncidentEntry` are required except
        `changelogAnchor` which is optional. A `reviewer` field
        identifies the actor for the audit log.
      properties:
        id:
          type: string
          description: Opaque slug for the incident (stable across curator edits).
        date:
          type: string
          format: date
          description: ISO date the incident started (YYYY-MM-DD).
        duration:
          type: string
          description: Human-readable incident duration (e.g. "42 minutes", "3 hours").
        severity:
          type: string
          enum: [low, medium, high, critical]
          description: Curator-assigned severity tier.
        summary:
          type: string
          description: One-sentence description of what happened.
        rootCause:
          type: string
          description: Plain-language root cause analysis.
        remediation:
          type: string
          description: What we changed to prevent recurrence.
        changelogAnchor:
          type: string
          description: Optional `/changelog#anchor` fragment linking to the related release notes.
        reviewer:
          type: string
          description: Reviewer identity for the audit log.
      required: [id, date, duration, severity, summary, rootCause, remediation]

    CreateIncidentResponse:
      type: object
      description: |
        Task #1057 — Response from `POST /api/incidents` confirming
        the new incident was appended and the version was bumped.
      properties:
        ok:
          type: boolean
        version:
          type: string
        incident:
          $ref: "#/components/schemas/IncidentEntry"
      required: [ok, version, incident]

    GalleryResponse:
      type: object
      description: |
        Curated sample-report gallery returned by `GET /api/gallery`. The
        `version` string lets clients cache-bust when curators ship a new
        revision of `gallery.json`.
      properties:
        version:
          type: string
        samples:
          type: array
          items:
            $ref: "#/components/schemas/GallerySample"
      required: [version, samples]

    ShowcaseEntry:
      type: object
      description: A single curator-picked interesting report shown on `/showcase`.
      properties:
        id:
          type: string
          description: Opaque slug for the showcase entry (stable across curator edits).
        title:
          type: string
          description: Card title.
        category:
          type: string
          enum: [high_confidence, edge_case, surprising_agreement]
          description: Curator-assigned bucket the entry illustrates.
        score:
          type: integer
          minimum: 0
          maximum: 100
          description: Final composite slop score for the report (0 clean → 100 slop).
        tier:
          type: string
          enum: [clean, borderline, subtle_slop, obvious_slop]
          description: Slop tier label that goes with the score.
        excerpt:
          type: string
          description: Short redacted excerpt from the report content.
        whyInteresting:
          type: string
          description: One-paragraph curator note explaining why this report was picked.
        reportId:
          type: integer
          minimum: 1
          description: Seed report id; click-through opens `/results/<reportId>`.
      required:
        [id, title, category, score, tier, excerpt, whyInteresting, reportId]

    ShowcaseResponse:
      type: object
      description: |
        Curated showcase entries returned by `GET /api/showcase`. The
        `version` string lets clients cache-bust when curators ship a
        new revision of `showcase.json`.
      properties:
        version:
          type: string
        entries:
          type: array
          items:
            $ref: "#/components/schemas/ShowcaseEntry"
      required: [version, entries]

    PresetLibrary:
      type: object
      description: |
        Curated public preset library returned by `GET /api/presets`. The
        `version` string lets clients cache-bust when curators ship a new
        revision of `presets.json`.
      properties:
        version:
          type: string
        presets:
          type: array
          items:
            $ref: "#/components/schemas/PresetEntry"
      required: [version, presets]

    ShowcaseNominationSubmitBody:
      type: object
      description: |
        Task #1048 — Public submission body for showcase nominations.
      properties:
        reportId:
          type: integer
          description: The ID of the report being nominated.
        reason:
          type: string
          minLength: 10
          maxLength: 1000
          description: One-paragraph explanation of why this report is interesting.
        email:
          type: string
          maxLength: 320
          description: Optional contact email for follow-up.
      required: [reportId, reason]

    ShowcaseNominationSubmitResponse:
      type: object
      properties:
        ok:
          type: boolean
        duplicate:
          type: boolean
          description: True when the same submitter already nominated this report in the last 24h.
        id:
          type: integer
          description: Newly assigned nomination id (omitted on duplicate responses).
        message:
          type: string
      required: [ok, duplicate, message]

    ShowcaseNominationRateLimited:
      type: object
      properties:
        error:
          type: string
        dailyLimit:
          type: integer
        retryAfterHours:
          type: integer
      required: [error]

    ShowcaseNomination:
      type: object
      properties:
        id:
          type: integer
        reportId:
          type: integer
        reason:
          type: string
        email:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, approved, rejected]
        createdAt:
          type: string
          format: date-time
      required: [id, reportId, reason, email, status, createdAt]

    ShowcaseNominationList:
      type: object
      properties:
        nominations:
          type: array
          items:
            $ref: "#/components/schemas/ShowcaseNomination"
        total:
          type: integer
        status:
          type: string
          enum: [pending, approved, rejected]
      required: [nominations, total, status]

    ShowcaseNominationPatchBody:
      type: object
      properties:
        status:
          type: string
          enum: [approved, rejected]
      required: [status]

    ShowcaseNominationPatchResponse:
      type: object
      properties:
        ok:
          type: boolean
        id:
          type: integer
        reportId:
          type: integer
        status:
          type: string
          enum: [approved, rejected]
      required: [ok, id, reportId, status]

    PhraseSuggestionSubmitBody:
      type: object
      description: |
        Task #634 — Public submission body. `text` is normalized
        (lowercase + collapsed whitespace) on the server before storage.
      properties:
        text:
          type: string
          minLength: 3
          maxLength: 240
          description: The proposed phrase.
        category:
          type: string
          enum: [handwavy, ai-self-disclosure]
          description: Which curated list the suggestion targets.
        context:
          type: string
          maxLength: 1000
          description: Optional reviewer-facing note (where the user saw it, why it matters).
      required: [text, category]

    PhraseSuggestionSubmitResponse:
      type: object
      properties:
        ok:
          type: boolean
        duplicate:
          type: boolean
          description: True when the same submitter already proposed this exact text in the last 24h.
        id:
          type: integer
          description: Newly assigned suggestion id (omitted on duplicate responses).
        message:
          type: string
      required: [ok, duplicate, message]

    PhraseSuggestionRateLimited:
      type: object
      properties:
        error:
          type: string
        dailyLimit:
          type: integer
        retryAfterHours:
          type: integer
      required: [error]

    PhraseSuggestion:
      type: object
      properties:
        id:
          type: integer
        text:
          type: string
        category:
          type: string
          enum: [handwavy, ai-self-disclosure]
        context:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, approved, rejected]
        createdAt:
          type: string
          format: date-time
      required: [id, text, category, context, status, createdAt]

    PhraseSuggestionList:
      type: object
      properties:
        suggestions:
          type: array
          items:
            $ref: "#/components/schemas/PhraseSuggestion"
        total:
          type: integer
        status:
          type: string
          enum: [pending, approved, rejected]
      required: [suggestions, total, status]

    PhraseSuggestionPatchBody:
      type: object
      properties:
        status:
          type: string
          enum: [approved, rejected]
      required: [status]

    PhraseSuggestionPatchResponse:
      type: object
      properties:
        ok:
          type: boolean
        id:
          type: integer
        status:
          type: string
          enum: [approved, rejected]
      required: [ok, id, status]

    Webhook:
      type: object
      description: |
        A reviewer-registered webhook subscription. The plaintext signing
        secret is never returned by this schema — it is shown exactly
        once on the WebhookCreateResponse at registration time.
      properties:
        id:
          type: integer
        url:
          type: string
        eventTypes:
          type: array
          items:
            type: string
            enum: [report.scored]
        createdAt:
          type: string
          format: date-time
        lastDeliveredAt:
          type: string
          format: date-time
          nullable: true
        failureCount:
          type: integer
      required: [id, url, eventTypes, createdAt, lastDeliveredAt, failureCount]

    WebhookList:
      type: object
      properties:
        webhooks:
          type: array
          items:
            $ref: "#/components/schemas/Webhook"
      required: [webhooks]

    WebhookCreateBody:
      type: object
      properties:
        url:
          type: string
          description: Destination URL (http or https) up to 1000 characters.
        eventTypes:
          type: array
          description: |
            Subset of supported events to subscribe to. Defaults to
            `[report.scored]` when omitted. v1 only supports
            `report.scored`.
          items:
            type: string
            enum: [report.scored]
      required: [url]

    WebhookCreateResponse:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          properties:
            secret:
              type: string
              description: |
                Per-webhook HMAC SHA-256 signing secret. Returned exactly
                once at registration time — only the SHA-256 hash is
                persisted server-side, so the caller must capture this
                value to verify incoming `X-VulnRap-Signature` headers.
                Re-register the webhook to receive a new secret.
          required: [secret]

    WebhookDeleteResponse:
      type: object
      properties:
        id:
          type: integer
      required: [id]

    TestYourselfLabel:
      type: string
      enum: [valid, invalid]
      description: |
        Binary label used by the bring-your-own fixture battery. `valid`
        means a real, well-substantiated vulnerability report; `invalid`
        means slop, fabrication, paraphrased CVE, or otherwise noise.
        The `valid` class is the positive class for precision / recall.

    TestYourselfRow:
      type: object
      properties:
        text:
          type: string
          description: Raw report text (1..50_000 characters).
          minLength: 1
          maxLength: 50000
        label:
          $ref: "#/components/schemas/TestYourselfLabel"
      required: [text, label]

    TestYourselfRunBody:
      type: object
      properties:
        rows:
          type: array
          minItems: 1
          maxItems: 50
          items:
            $ref: "#/components/schemas/TestYourselfRow"
      required: [rows]

    TestYourselfPerRow:
      type: object
      properties:
        index:
          type: integer
          description: Zero-based row index in the submitted battery.
        textPreview:
          type: string
          description: First 240 chars of the row's text, for display.
        expectedLabel:
          $ref: "#/components/schemas/TestYourselfLabel"
        predictedLabel:
          $ref: "#/components/schemas/TestYourselfLabel"
        compositeScore:
          type: integer
          description: Composite score 0..100 (higher = stronger evidence of a real vulnerability).
        compositeLabel:
          type: string
          description: Engine's raw composite label (e.g. `LIKELY INVALID`, `STRONG`).
        correct:
          type: boolean
          description: True when predictedLabel === expectedLabel.
      required:
        - index
        - textPreview
        - expectedLabel
        - predictedLabel
        - compositeScore
        - compositeLabel
        - correct

    TestYourselfConfusionMatrix:
      type: object
      properties:
        truePositive:
          type: integer
          description: Predicted `valid` and actually `valid`.
        falsePositive:
          type: integer
          description: Predicted `valid` but actually `invalid`.
        trueNegative:
          type: integer
          description: Predicted `invalid` and actually `invalid`.
        falseNegative:
          type: integer
          description: Predicted `invalid` but actually `valid`.
      required: [truePositive, falsePositive, trueNegative, falseNegative]

    TestYourselfAggregate:
      type: object
      properties:
        total:
          type: integer
        accuracy:
          type: number
          format: float
          description: Fraction of rows where predictedLabel === expectedLabel.
        precision:
          type: number
          format: float
          description: TP / (TP + FP). 0 when TP+FP == 0.
        recall:
          type: number
          format: float
          description: TP / (TP + FN). 0 when TP+FN == 0.
        f1:
          type: number
          format: float
          description: Harmonic mean of precision and recall. 0 when both are 0.
        confusionMatrix:
          $ref: "#/components/schemas/TestYourselfConfusionMatrix"
      required: [total, accuracy, precision, recall, f1, confusionMatrix]

    TestYourselfRunResponse:
      type: object
      properties:
        aggregate:
          $ref: "#/components/schemas/TestYourselfAggregate"
        perRow:
          type: array
          items:
            $ref: "#/components/schemas/TestYourselfPerRow"
        rateLimit:
          type: object
          properties:
            limit:
              type: integer
              description: Maximum runs allowed per IP per day.
            remaining:
              type: integer
              description: Runs remaining for this IP in the current 24h window.
          required: [limit, remaining]
      required: [aggregate, perRow, rateLimit]
