openapi: 3.1.0
info:
  title: Premium Exposure + Removal API
  version: "1.2.0"
  description: >
    Async job API for the premium exposure product (deep scan + auto-removal),
    running on nydar off Apify. This document is NORMATIVE for the HTTP contract
    (spec docs/SPEC-EXPOSURE-PREMIUM-API.md §17): where prose and this YAML differ
    on request/response shape, this YAML wins. Stage 1 performs the deep scan;
    removal is recorded as 'deferred' until stage 2 (REMOVAL_ENABLED).

    BILLING: one billable job costs one prepaid credit and one daily-quota point.
    Reads cost neither — polling a job, listing jobs and checking your balance are
    free, so poll rather than holding connections open. A cached result (identical
    subject inside the 48-hour window, or an identical job still running) is not
    billed. Check your balance at /v1/premium/credits, or in a browser at
    /v1/premium/dashboard.
servers:
  - url: https://api.apifyforge.com
paths:
  /v1/premium/status:
    get:
      summary: Operational status of the engine
      description: >-
        Is the engine working right now, and can you see that yourself? Source health is fed by
        REAL customer scans rather than synthetic probes, so a parser that drifts shows up here
        instead of being inferred from a coverage dip weeks later.

        `observed` can trail `configured` until every source has been scanned at least once —
        reporting them as one number would imply coverage that has not been measured. Status bands
        are by consecutive failures: one failure is a bad proxy exit or a slow render, three is
        drift worth investigating.

        Source and scheduler blocks are platform-wide; the queue block is owner-scoped.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Engine status
          content:
            application/json:
              schema:
                type: object
                properties:
                  asOf: { type: string, format: date-time }
                  build: { type: string }
                  sources:
                    type: object
                    properties:
                      configured: { type: integer }
                      observed: { type: integer, description: Sources scanned at least once }
                      healthy: { type: integer }
                      degraded: { type: integer }
                      unreadable: { type: integer }
                      detail:
                        type: array
                        items:
                          type: object
                          properties:
                            domain: { type: string }
                            status: { type: string, enum: [healthy, degraded, unreadable] }
                            lastOkAt: { type: [string, "null"], format: date-time }
                            lastFailAt: { type: [string, "null"], format: date-time }
                            lastOutcome: { type: [string, "null"] }
                            consecutiveFailures: { type: integer }
                  scheduler:
                    type: object
                    description: >-
                      The reconciler drives the 30-day re-check and the send drain. Stale means both
                      stopped, which is invisible from outside until removals quietly stop moving.
                    properties:
                      lastBeatAt: { type: [string, "null"], format: date-time }
                      stale: { type: boolean }
                      ageSeconds: { type: [integer, "null"] }
                  removals:
                    type: object
                    properties:
                      queue: { type: object }
        "401": { $ref: "#/components/responses/Error" }
  /v1/premium/removals/completion:
    get:
      summary: Verified-effective completion for your own removals
      description: >-
        Owner-scoped. `completionRate` is **null**, never 0, when nothing has matured — "no matured
        evidence" and "zero completions" are different statements and must not be collapsed.
        Rows belonging to an internal pipeline-proof cohort are excluded in the query, not by
        convention, because that cohort is chosen for responsiveness and would inflate any rate.
        `reappeared` stays IN the denominator and out of the numerator: excluding it from both
        would hide reversals.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Completion rollup
          content:
            application/json:
              schema:
                type: object
                properties:
                  asOf: { type: string, format: date-time }
                  cohort:
                    type: object
                    properties:
                      filedFrom: { type: string, nullable: true }
                      filedTo: { type: string, nullable: true }
                      maturedThrough: { type: string, format: date-time }
                  totals: { type: object, description: Mutually exclusive outcome taxonomy }
                  coverage:
                    type: object
                    description: Split by class; only rescan-verifiable sources can reach scan_verified
                    properties:
                      rescanVerifiable: { type: object }
                      attestationOnly: { type: object }
                  providerAttested: { type: integer, description: The BROKER's own claim }
                  scanVerified: { type: integer, description: OUR independent rescan. Not the same thing }
                  reappeared: { type: integer }
                  matureAttributable: { type: integer, description: The denominator }
                  completionRate: { type: number, nullable: true }
                  definitionVersion: { type: string }
                  excludesProofCohort: { type: boolean }
        "401": { $ref: "#/components/responses/Error" }
  /v1/premium/removals/queue:
    get:
      summary: Your position in the statutory filing queue
      description: >-
        Owner-scoped. Outbound send capacity is SHARED across all customers while sending domains
        warm, so a partner cannot infer their wait from their own volume. `estimatedClearanceAt` is
        computed from `globalQueued`, because everything ahead files first — using your own count
        would understate the wait exactly when another tenant is busy. It is a projection at the
        current cap, not a commitment; `basis` says so in the payload.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Queue position and projected clearance
          content:
            application/json:
              schema:
                type: object
                properties:
                  partnerQueued: { type: integer }
                  globalQueued: { type: integer }
                  oldestQueuedAt: { type: string, format: date-time, nullable: true }
                  dailyCapacity: { type: integer, description: Summed from the live warming ramp }
                  capacityShared: { type: boolean, description: Always true today }
                  estimatedClearanceAt: { type: string, format: date-time, nullable: true }
                  basis: { type: string }
        "401": { $ref: "#/components/responses/Error" }
  /v1/premium/jobs:
    post:
      summary: Create a premium job (deep scan, optionally + removal)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, proxy]
              properties:
                mode:
                  type: string
                  enum: [scan, "scan+remove"]
                  default: scan
                subject:
                  type: object
                  required: [firstName, lastName]
                  properties:
                    firstName: { type: string }
                    lastName: { type: string }
                    middleName: { type: string, description: "Legal middle name. Used with date of birth to identify the right records at removal time; ignored for scanning." }
                    state: { type: string, description: US state code; strongly recommended }
                    city: { type: string }
                    email: { type: string, description: "Encrypted at rest. Returns breach data. Note it rarely improves broker match confidence — brokers do not publish email addresses, so it cannot confirm a listing is this person." }
                    phone: { type: string, description: "Encrypted at rest. The BEST identifier for match confidence: brokers publish partial phone numbers, so it can confirm a listing belongs to this person." }
                    dateOfBirth:
                      type: string
                      pattern: '^\d{4}-\d{2}-\d{2}$'
                      description: >
                        YYYY-MM-DD. REQUIRED for mode=scan+remove, ignored for scanning — do
                        not collect it for a scan-only request. Brokers match records by name
                        AND date of birth, and they bind it PERMANENTLY to the suppression
                        account: it cannot be corrected afterwards, so confirm it in your UI
                        rather than accepting a bare date field. A typo permanently breaks
                        that person's suppression.
                    alsoKnownAs:
                      type: array
                      maxItems: 5
                      description: Former or informal names the records may be filed under (maiden name, or a middle name used as a first name).
                      items:
                        type: object
                        required: [firstName, lastName]
                        properties:
                          firstName: { type: string }
                          middleName: { type: string }
                          lastName: { type: string }
                    profileType:
                      type: string
                      enum: [standard, executive, journalist, public-official, healthcare, legal]
                authorization:
                  type: object
                  description: >
                    Required when mode includes remove (spec §5, authorized-agent
                    boundary). We store only this metadata — never the signed document.
                  required: [ref]
                  properties:
                    ref: { type: string, description: FaceOnLive's reference to the signed consumer authorization }
                    hash: { type: string, description: Hash of the signed document FaceOnLive retains }
                    signedAt: { type: string, format: date-time }
                    issuer: { type: string }
                    version: { type: string }
                proxy:
                  type: string
                  description: Evomi proxy URL. SSRF-validated against the allowlist. Encrypted at rest, never returned.
                webhookUrl:
                  type: string
                  format: uri
                  description: HTTPS callback for terminal status. SSRF-validated (no private ranges) at creation and before each delivery.
                fresh:
                  type: boolean
                  default: false
                  description: >
                    Force a real re-run instead of returning a cached result. An
                    identical request within 48 hours normally returns the existing job
                    with "cached": true and is not charged. Set this when you need
                    current data, for example to confirm a removal dropped a listing.
                    Charged as a normal request.
      responses:
        "202":
          description: Job accepted and queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string, description: ULID }
                  status: { type: string, description: "queued for a new job; a cached hit returns the existing job's status" }
                  cached: { type: boolean, description: True when an existing job was returned instead of running new work. Not billed. Either a completed job inside the 48-hour window, or an identical job still running (submit twice by accident, or retry a timeout, and you join the running job rather than paying for a second one). }
                  creditsRemaining: { type: integer, description: "Prepaid credits left after this job. Omitted for keys with unlimited credits. May be negative if you have overdrawn; jobs continue to be served." }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error", description: "No prepaid credits remaining (only returned when credit enforcement is enabled for your key)" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
    get:
      summary: List the calling key's jobs (operational)
      security: [{ bearerAuth: [] }]
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [queued, scanning, removing, done, completed_with_errors, failed, cancelled]
      responses:
        "200":
          description: Job summaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: "#/components/schemas/JobSummary" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/premium/credits:
    get:
      summary: Prepaid credit balance and remaining daily quota
      description: >
        A read. Costs no credit and no daily quota, so it is safe to call as often as
        you like. A human-readable version of the same data is at
        /v1/premium/dashboard.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Current balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  unlimited: { type: boolean, description: True for keys with no credit limit; granted and remaining are then null }
                  granted: { type: [integer, "null"], description: Credits purchased }
                  used: { type: [integer, "null"], description: Billable jobs created against this key }
                  remaining: { type: [integer, "null"], description: "granted minus used. Negative when overdrawn." }
                  dailyQuota: { type: integer, description: Billable jobs permitted per rolling 24 hours }
                  dailyRemaining: { type: integer, description: "Billable jobs left today, or -1 when unknown" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/premium/jobs/{jobId}:
    get:
      summary: Get a job (status + result when done)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Job
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      summary: Cancel a job
      description: >
        queued → cancelled immediately; scanning → best-effort; removing → cannot
        interrupt the site in flight; already-submitted removals stand.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Job after the cancel attempt
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/premium/sandbox/scan:
    get:
      summary: Deterministic fixture scan results (no job, no credit, no customer data)
      description: >
        Fixture data for integration work, returning the states most often rendered wrongly rather
        than a happy path.

        Wire tests against `inconclusive` and `candidates` in particular. `inconclusive` returns
        exposedCount 0 with canAssertClean FALSE, which means UNKNOWN, not clean — rendering it as
        reassurance is the one genuinely harmful way to be wrong in a privacy report. `candidates`
        returns a finding whose evidenceState is `indexed_candidate`, meaning a search index matched
        a URL and title but the live page was never fetched; it must be labelled differently from
        `current_page_confirmed`.

        Byte-stable across calls so it can be used in your own assertions.
      security: [{ bearerAuth: [] }]
      parameters:
        - in: query
          name: scenario
          schema:
            type: string
            enum: [exposed, clean, inconclusive, candidates, blocked]
            default: exposed
      responses:
        "200":
          description: Fixture scan
          content:
            application/json:
              schema:
                type: object
                properties:
                  sandbox: { type: boolean, enum: [true] }
                  scenario: { type: string }
                  scan: { type: object }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/premium/sandbox/webhooks:
    get:
      summary: Webhook fixtures including the failure modes
      description: >
        Signed with YOUR key's real webhook secret, so signature verification can be exercised
        end to end before a live job exists.

        Deliveries are at-least-once, so the cases that matter are not the happy path: an invalid
        signature (signed with a different secret, so it verifies as wrong rather than malformed), an
        expired timestamp outside the ±5 minute replay window, a duplicate X-Delivery-Id, a retry
        after a 500 carrying the same delivery id, and an out-of-order event whose payload occurredAt
        precedes one already delivered.

        Verify over the RAW body bytes — re-serialising the JSON changes key order and breaks the
        HMAC. Dedupe on X-Delivery-Id. Order state by payload occurredAt, never by arrival time.
      security: [{ bearerAuth: [] }]
      parameters:
        - in: query
          name: case
          schema:
            type: string
            enum: [valid, invalid_signature, expired_timestamp, duplicate_delivery_id, retry_after_500, delayed, out_of_order, unknown_event_version]
      responses:
        "200":
          description: Fixtures with the expected receiver behaviour for each
          content:
            application/json:
              schema:
                type: object
                properties:
                  sandbox: { type: boolean }
                  signatureScheme: { type: string }
                  replayWindowSeconds: { type: integer }
                  fixtures:
                    type: array
                    items:
                      type: object
                      properties:
                        case: { type: string }
                        headers: { type: object, additionalProperties: { type: string } }
                        body: { type: string }
                        expectedReceiverBehaviour: { type: string }
        "400": { $ref: "#/components/responses/Error" }
        "409": { description: This key has no webhook secret configured }
  /v1/premium/jobs/{jobId}/evidence:
    get:
      summary: Screenshot evidence metadata for a job's findings
      description: >
        Metadata only; the bytes come from `/v1/premium/evidence/{evidenceId}` after a second
        owner check. No pre-signed URLs appear here by design — authorisation happens before a URL is
        minted, and a list carrying durable links would hand out capabilities to anyone able to read
        (or cache) the list.

        What a screenshot establishes is narrow: what our scanner rendered, at a time, from a URL,
        under a stated configuration. It does NOT establish that the record belongs to the subject,
        that an ordinary visitor would see the same page, or that the state persisted. `contentHash`
        is tamper detection, not authentication, and is not a certification of authenticity.

        First release covers direct-scan findings only. Search-derived confirmations are excluded
        until their validation precision is proven in production.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: jobId, required: true, schema: { type: string } }
      responses:
        "200":
          description: Evidence records
          content:
            application/json:
              schema:
                type: object
                properties:
                  evidence:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        findingDomain: { type: string }
                        contentType: { type: string }
                        byteSize: { type: integer }
                        contentHash: { type: string, description: SHA-256 of the captured bytes. Tamper detection only. }
                        capturedAt: { type: string, format: date-time }
                        expiresAt: { type: string, format: date-time }
                  retentionDays: { type: integer }
        "404": { $ref: "#/components/responses/Error" }
  /v1/premium/evidence/{evidenceId}:
    get:
      summary: Fetch screenshot bytes (owner-scoped)
      description: >
        Returns the image. Owner authorisation happens before any bytes are read, and both "not
        yours" and "not found" answer 404 so this cannot be used to probe for other tenants'
        evidence. Refused once past its retention window even if the sweep has not yet run.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: evidenceId, required: true, schema: { type: string } }
      responses:
        "200":
          description: Image bytes
          content:
            image/png:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/Error" }
  /v1/premium/jobs/{jobId}/disputes:
    post:
      summary: Report that one of our findings is wrong
      description: >
        A correction lane on our own evidence, not a support ticket.

        A dispute NEVER mutates the finding it concerns: a partner allegation is not itself evidence
        that we were wrong, so the original assertion survives verbatim and an upheld dispute produces
        an append-only correction event.

        Safety consequences DO apply on submission, before triage, because the costs are asymmetric.
        `wrong_subject` immediately withholds the finding and blocks any statutory filing against it —
        filing on a misattributed finding would send a stranger's data to a broker in our name.
        `false_absence` immediately revokes the scan's clean assertion and schedules a re-scan.

        Send `Idempotency-Key` when retrying; without it a retry creates a second dispute and inflates
        the published rate denominator.

        `evidenceUrl` is stored as text and never dereferenced by this service.
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: jobId, required: true, schema: { type: string } }
        - { in: header, name: Idempotency-Key, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, findingDomain]
              properties:
                type:
                  type: string
                  enum: [wrong_subject, false_absence, stale_exposure, incorrect_broker_attestation, incorrect_source_classification, other]
                findingDomain: { type: string }
                expectedState: { type: string }
                observedState: { type: string }
                reporter: { type: string }
                evidenceUrl: { type: string, description: Stored as text. Never fetched by this service. }
                note: { type: string }
      responses:
        "201": { description: Dispute filed }
        "200": { description: Existing dispute returned (idempotent replay) }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    get:
      summary: Disputes filed against this job
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: jobId, required: true, schema: { type: string } }
      responses:
        "200": { description: Disputes }
        "404": { $ref: "#/components/responses/Error" }
  /v1/premium/disputes/rates:
    get:
      summary: Our own error rates, with denominators
      description: >
        Published deliberately. `perThousandAssessments` is the figure that means something — a raw
        dispute count flatters us at low volume and damns us at high volume, for reasons unrelated to
        evidence quality. It is null, never 0, on an empty denominator.
      security: [{ bearerAuth: [] }]
      parameters:
        - in: query
          name: scope
          schema: { type: string, enum: [owner, platform], default: owner }
      responses:
        "200":
          description: Rates
          content:
            application/json:
              schema:
                type: object
                properties:
                  disputes: { type: integer }
                  sourceAssessments: { type: integer }
                  perThousandAssessments: { type: [number, "null"] }
                  upheld: { type: integer }
                  upheldRate: { type: [number, "null"] }
                  wrongSubjectUpheld: { type: integer }
                  falseAbsenceUpheld: { type: integer }
                  medianResolutionHours: { type: [number, "null"] }
                  oldestOpenAgeHours: { type: [number, "null"] }
  /v1/premium/openapi.yaml:
    get:
      summary: This document
      description: >
        Returns the current OpenAPI contract. Requires a valid key but does not count
        against your daily quota, so you can fetch it whenever you regenerate a client.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: The OpenAPI 3.1 document
          content:
            application/yaml:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Error" }
  /health:
    get:
      summary: Liveness (does not touch Redis/Postgres)
      responses:
        "200": { description: OK }
  /ready:
    get:
      summary: Readiness (checks Redis + Postgres)
      responses:
        "200": { description: Ready }
        "503": { description: Dependency degraded }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  schemas:
    JobSummary:
      type: object
      properties:
        jobId: { type: string }
        status: { type: string }
        mode: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        estimatedCompletion: { type: [string, "null"], format: date-time }
    Exposure:
      type: object
      properties:
        site: { type: string }
        domain: { type: string }
        profileUrl: { type: [string, "null"] }
        dataTypes: { type: array, items: { type: string } }
        severity: { type: integer }
        priority: { type: string, enum: [urgent, high, medium, low] }
        matchStrength: { type: [string, "null"], enum: [strong, moderate, weak, null] }
        brokerAuthority: { type: string, enum: [high, medium, low] }
        removalUrl: { type: string }
        difficulty: { type: string }
        removable: { type: boolean }
        captchaRequired: { type: boolean }
        confirmationMode: { type: string, enum: [delegated, consumer, either, none] }
        evidenceState:
          type: string
          enum: [indexed_candidate, current_page_confirmed, subject_not_observed, current_page_unreadable, wrong_subject]
          description: >
            Present ONLY on search-derived findings; absent on direct-scan findings, where scanStatus
            already carries the verdict.

            `indexed_candidate` means a search index held a URL whose title matched a name — the live
            page was NOT fetched, so this is not established exposure and must be labelled differently
            from a confirmed one. `current_page_confirmed` means the page was fetched, classified as an
            individual profile, and matched the subject on content-derived attributes.
            `subject_not_observed` means fetched but not confirmed, which is NOT a claim of absence.
        notConfirmedReason:
          type: string
          enum: [stale_index, generic_redirect, soft_404, page_changed, identity_insufficient, parser_uncertain]
          description: >
            Why a fetched candidate did not confirm. Always accompanies `subject_not_observed`, because
            "we could not confirm" and "they are not there" are different facts and only the first is
            what this state records.
        identitySignals:
          type: array
          description: >
            Which content-derived attributes tied the page to the subject (city, state, phone_suffix,
            email_fragment). Named rather than counted so a confirmation can be re-examined. URL
            structure is deliberately never one of these: a broker builds its URLs from the same name
            we searched for, so a matching URL is our own query reflected back, not evidence.
          items: { type: string }
        found:
          type: boolean
          deprecated: true
          description: >
            DEPRECATED. Carries pre-2026-07-30 semantics while the search lane runs in `shadow` mode,
            where an unvalidated search-index hit sets it true. Read `evidenceState` instead: it
            distinguishes an index candidate from a validated live-page confirmation. Once the lane is
            in `enforce` mode this field is derived only from confirmed states.
    Unverified:
      type: object
      properties:
        site: { type: string }
        domain: { type: string }
        reason:
          type: string
          enum: [blocked, error, unvalidated]
          description: >
            `blocked` = anti-bot refusal. `error` = unreachable. `unvalidated` = recognised in a
            search index but the page was never fetched, so nothing failed and nothing was
            established — it cannot support a clear result and is listed here for that reason alone.
            Treat all three as "not checked", never as "not listed".
        status: { type: [integer, "null"], description: HTTP status where one was returned }
    Removal:
      type: object
      properties:
        site: { type: string }
        domain: { type: string }
        status:
          type: string
          # Must stay in step with RemovalStatus in src/types.ts. It had drifted: needs_input,
          # no_records and undeliverable were all live in the service and absent here, so a
          # client generated from this contract would reject responses the API actually sends.
          enum: [queued, submitted, needs_input, confirmed, failed, captcha_required,
                 not_removable, no_records, undeliverable, deferred]
          description: >
            queued — accepted, not yet sent.
            submitted — sent to the broker, awaiting a reply.
            needs_input — the broker asked for something (address, DOB, proof of authority)
            before it will proceed; sent, not failed, not confirmed.
            confirmed — the broker confirmed the removal.
            failed — the broker received the request and declined it.
            no_records — the broker found no matching record. Terminal, and NOT a failure.
            undeliverable — the request never reached the broker (a hard bounce for its
            compliance address). Distinct from failed, which is a decision the broker made.
            Counted in neither "filed" nor "pending".
            captcha_required / not_removable / deferred — not sent.
        confirmationCode: { type: [string, "null"] }
        submittedAt: { type: [string, "null"], format: date-time }
        recheckDueAt: { type: [string, "null"], format: date-time }
        reason:
          type: string
          description: >
            Human-readable audit label. NOT machine-readable — other subsystems rewrite it
            (a re-check appends its note). Never key logic on its contents; use `status` and
            `evidence`.
        channel:
          type: string
          description: How the request was delivered — email | browser | drop.
        optOutUrl:
          type: [string, "null"]
          description: The broker's own published statutory opt-out URL.
        needsInput:
          type: [object, "null"]
          description: >
            Present only when status is needs_input — the structured demand the broker made.
          properties:
            requiredField: { type: string, description: postal_address | date_of_birth | proof_of_authority | other }
            reason: { type: string }
            sourceBroker: { type: string }
            deadlineAt: { type: [string, "null"], format: date-time }
        evidence:
          type: object
          description: >
            The checkable trail behind this removal's status. Timestamps, not booleans: null means
            it never happened, and a date says when, which is what makes a completion claim
            auditable rather than asserted.

            ⚠ `providerAttestedAt` and `scanVerifiedAt` are DIFFERENT CLAIMS and are never derived
            from each other. The first is the broker asserting an outcome — a claim by an
            interested party. The second is our own independent re-scan finding the listing gone.
            Merging them is how a completion rate stops being a measurement.

            Derived metadata only; raw broker messages are never exposed here.
          properties:
            emailSentAt:
              type: [string, "null"]
              format: date-time
              description: When the statutory request actually went out.
            providerAttestedAt:
              type: [string, "null"]
              format: date-time
              description: When the BROKER asserted an outcome. Set once, never overwritten.
            scanVerifiedAt:
              type: [string, "null"]
              format: date-time
              description: When OUR independent re-scan confirmed the listing gone. Verification.
            reappearedAt:
              type: [string, "null"]
              format: date-time
              description: >
                When the listing was last seen to have returned after removal. HISTORY — never
                cleared. If `scanVerifiedAt` is later, it came back and was removed again; compare
                the two rather than testing for presence.
    RemovalRollup:
      type: object
      # Served by view.ts on every job response, and previously undocumented here entirely.
      # Mirrors RemovalRollup in src/rollups.ts.
      description: >
        Delivery summary across a job's removals. Deliberately does NOT claim "removed":
        only `confirmed` reflects a real broker confirmation. `filed` counts requests that
        actually reached a broker, so it excludes both not-yet-sent and undeliverable ones.
      properties:
        total: { type: integer, description: Every removal record on the job. }
        filed:
          type: integer
          description: >
            Requests that actually reached a broker (submitted plus terminal outcomes).
            Excludes queued/deferred, which were never sent, and undeliverable, which
            bounced — "we could not file" is never reported as "we filed".
        confirmed: { type: integer, description: The broker confirmed the removal. }
        rejected: { type: integer, description: The broker received the request and declined it. }
        noRecords: { type: integer, description: Sent; the broker held no matching record. Terminal, not a failure. }
        pending: { type: integer, description: Awaiting a broker reply, or not yet sent. }
        undeliverable:
          type: integer
          description: >
            The request never reached the broker — a hard bounce for its compliance address.
            Counted in neither `filed` nor `pending`: nothing is awaited, because no reply can
            come.
        byChannel:
          type: object
          description: The same buckets split by delivery channel (email / browser / drop).
          additionalProperties: true
    ScanResult:
      type: object
      properties:
        riskScore: { type: integer }
        riskLevel: { type: string, enum: [critical, high, moderate, low, minimal] }
        exposedCount: { type: integer }
        sitesScanned: { type: integer }
        sitesBlocked: { type: integer, description: Sources that returned an anti-bot block }
        sitesErrored: { type: integer, description: Sources that could not be reached at all }
        unverified:
          type: array
          description: >
            Sources that could NOT be checked. These are never counted as clean,
            because reporting an unreachable source as "not listed" would be a false
            negative. Surface these to end users rather than hiding them.
          items: { $ref: "#/components/schemas/Unverified" }
        exposureByLevel:
          type: object
          properties:
            high: { type: integer }
            medium: { type: integer }
            low: { type: integer }
        exposures: { type: array, items: { $ref: "#/components/schemas/Exposure" } }
        threatScenarios: { type: array, items: { type: object } }
        remediationQueue: { type: array, items: { type: object } }
        dataTypesExposed: { type: array, items: { type: string } }
        scannedAt: { type: string, format: date-time }
        disputedWithheld:
          type: array
          description: >
            Domains withheld from this result because a dispute reports the finding concerns the
            wrong person. Present only when something was withheld. The stored assertion is
            unchanged — corrections are append-only — so this documents what is being suppressed
            on presentation rather than a rewrite of what we originally said.
          items: { type: string }
        searchLayer:
          type: object
          description: >
            What the search lane did and how far to trust it. Present whenever the lane RAN,
            including when its results were discarded.
          properties:
            checked: { type: boolean, description: False when the lane ran but produced nothing usable. }
            complete:
              type: boolean
              description: >
                False means the search was PARTIAL, not that it found nothing. A listing the missing
                query would have surfaced can be absent from this result, so do not treat a partial
                search as exhaustive.
            domainsCovered: { type: integer, description: Domains a result was matched against. Presence-only. }
            searchRecognisableDomains: { type: integer, description: Same figure under the §0a name. }
            domainsQueried: { type: integer, description: Domains individually targeted by a site-scoped query. }
            requestedPages: { type: integer }
            completedPages: { type: integer }
            provider: { type: string }
            partialReason:
              type: [string, "null"]
              enum: [rate_limited, provider_error, budget_exhausted, deadline, query_altered, null]
            queryAltered:
              type: [string, "null"]
              description: >
                Non-null means the search provider rewrote our query despite spell-checking being
                disabled, so the query that ran is not the query we sent and the candidates cannot be
                attributed to this subject. The batch is QUARANTINED and no findings from it appear
                in this result. Treat the search lane as not having run.
            validationMode:
              type: string
              enum: [shadow, enforce, degraded]
              description: >
                `shadow` — search findings still carry pre-2026-07-30 semantics, where a search-index
                hit counts as confirmed. `enforce` — only findings validated against the live page
                count toward exposedCount. `degraded` — the measured false-confirmation rate breached
                its ceiling and nothing from this lane is presented as confirmed.
            candidates:
              type: object
              description: >
                How many search findings were VALIDATED against the live page versus merely present
                in a search index. The honest core of this block: `indexed` findings have not been
                verified to exist or to concern this subject.
              properties:
                indexed: { type: integer, description: Index entry matched; live page not fetched. }
                confirmed: { type: integer, description: Fetched, an individual profile, identity matched on page content. }
                notObserved: { type: integer, description: Fetched but not confirmed. NOT a claim of absence. }
                unreadable: { type: integer, description: Fetch blocked or errored. Unresolved. }
                wrongSubject: { type: integer, description: The live page concerns a different named person. Suppressed. }
    Job:
      type: object
      properties:
        jobId: { type: string }
        mode: { type: string, enum: [scan, "scan+remove"] }
        status: { type: string, enum: [queued, scanning, removing, done, completed_with_errors, failed, cancelled] }
        estimatedCompletion: { type: [string, "null"], format: date-time }
        scan:
          oneOf:
            - { $ref: "#/components/schemas/ScanResult" }
            - { type: "null" }
        removals: { type: array, items: { $ref: "#/components/schemas/Removal" } }
        removalRollup: { $ref: "#/components/schemas/RemovalRollup" }
        error: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    Error:
      type: object
      properties:
        error:
          type: string
          enum: [invalid_request, unauthorized, rate_limited, not_found, internal]
        detail: { type: string }
        retryAfter: { type: integer }
        requestId: { type: string }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: >
        Rate limited. Two independent limits apply. A short burst limit applies to every
        request. The DAILY quota counts BILLABLE JOBS only — reads (polling a job,
        listing jobs, checking credits, fetching this spec) never consume it, so poll as
        often as you need to.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  # ---------------------------------------------------------------------------
  # Webhook (out-of-band): on terminal status the service POSTs the Job body to
  # the caller's webhookUrl, signed so it can't be spoofed (spec §6).
  #   X-Signature: sha256=<HMAC-SHA256(webhookSecret, rawBody)>
  #   X-Timestamp: <unix seconds>   (receiver enforces a ±5-min replay window)
  #   X-Delivery-Id: <ULID>         (at-least-once — dedupe on this)
  # Body is the Job schema plus a "deliveryId" field. Retried with backoff on non-2xx.
  # ---------------------------------------------------------------------------
