openapi: 3.0.3
info:
  title: ITManagerJobs Public API
  description: |
    Stable public surface for ATS integrations and partner automation.
    All endpoints under `/api/v1/...` accept the same auth (API key over
    Bearer or `X-Api-Key`). Endpoints are tenant-scoped to the key's
    employer; cross-tenant calls return `404`.

    Scopes:
      - `JobPosting`   — job CRUD + publish
      - `Applications` — applicant pipeline (read, AI assessment, status
        transitions) plus the recruiter ATS surface (tags, notes,
        interview scorecards)
      - `CvSearch`     — boolean CV search and per-candidate unlock

    Each key carries a fixed set of scopes chosen at mint time. Endpoints
    reject keys that don't carry the right scope with `403`.

    Contract stability: the response/request shape on `/api/v1/...` is
    locked. Breaking changes ship under `/api/v2/...`; v1 stays live for
    the announced deprecation window.
  version: "1.0"
  contact:
    name: Platform support
    url: https://itmanagerjobs.com/contact
servers:
  - url: https://itmanagerjobs.com
    description: Production
  - url: http://localhost:5042
    description: Local dev (run `dotnet run --project JobBoardAPI/JobBoardAPI.csproj`)

security:
  - ApiKeyBearer: []
  - ApiKeyHeader: []

tags:
  - name: Jobs
    description: Job posting CRUD and publish lifecycle. Scope `JobPosting`.
  - name: Applications
    description: Applicant pipeline + AI assessment + status. Scope `Applications`.
  - name: Tags
    description: Recruiter-only candidate labels. Scope `Applications`.
  - name: Notes
    description: Recruiter-only candidate notes. Scope `Applications`.
  - name: Scorecards
    description: Interviewer scorecards per stage. Scope `Applications`.
  - name: Candidates
    description: CV search and per-candidate unlock. Scope `CvSearch`. Credit-billed.
  - name: Me
    description: |
      Candidate-self surface — read / update your own profile, list your own
      applications. Authenticated with a separate `CandidateApiKey` scheme
      (scopes `Profile`, `Applications`) issued by the candidate themselves
      from `/candidate/api-keys`. The `Apply` scope is reserved for a
      future safeguarded apply endpoint and is not accepted at mint time
      yet.
  - name: Auth
    description: |
      Identity introspection. The `whoami` endpoint resolves a bearer
      credential to its principal (employer vs candidate), granted scopes,
      and the tenant's primary host. Lets MCP / CLI clients drop the
      explicit base-URL config — paste the API key, the server returns
      where subsequent calls should land.
  - name: Shortlist
    description: |
      Employer-wide saved-candidate list. Shared across all recruiters
      on the tenant. One shortlist per employer; candidates added by
      `JobSeeker` id, idempotent on duplicates. Scope `Applications`.
      MCP consumers see a projection that strips the recruiter-internal
      `note` field; CLI consumers see it verbatim.
  - name: ReviewRequests
    description: |
      Internal hiring-team coordination. A recruiter asks a colleague
      to review an application; the colleague gets an email and the
      request appears in their `/review-requests/mine` queue.
      Completion can carry a brief response note; full structured
      feedback goes via the Scorecard endpoints. Scope `Applications`.
      Recipients are always same-tenant users; the create endpoint
      accepts `assignedToUserId` OR `assignedToEmail`. MCP wraps create
      in a `confirm=true` gate (sends email).
  - name: Employer
    description: |
      Tenant-scoped helpers. Currently just a read-only listing of
      same-tenant employer-side users — exists so MCP/CLI callers can
      resolve a colleague's id by email before raising a review
      request. Scope `Applications`.
  - name: ScreeningQuestions
    description: |
      Two-layer recruiter model: a per-employer **library** of reusable
      questions plus a **per-job attachment** picking which questions
      appear at apply-time. Question types: YesNo / FreeText /
      SingleChoice / MultiChoice / Numeric. Soft-delete only.

      Library CRUD + per-job attach are gated on the `JobPosting`
      scope. The recruiter-side answer read is gated on `Applications`.
      The candidate-side read (`/api/v1/me/jobs/{id}/screening-questions`,
      scope `Profile`) and the public anonymous embed inside
      `/api/public/jobs/{id}.screeningQuestions` NEVER return the
      recruiter's `CorrectAnswer` — exposing it would defeat the
      screening intent.

paths:

  # ── Jobs ─────────────────────────────────────────────────────────

  /api/v1/jobs:
    get:
      tags: [Jobs]
      summary: List jobs
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [Draft, Active, Inactive, Expired] }
          required: false
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/pageSize"
      responses:
        "200":
          description: Paginated list of jobs owned by the calling employer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1ListResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Jobs]
      summary: Create a draft job
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobV1CreateRequest" }
      responses:
        "201":
          description: Job created as Draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1Detail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/jobs/{id}:
    parameters:
      - $ref: "#/components/parameters/jobId"
    get:
      tags: [Jobs]
      summary: Get one job
      responses:
        "200":
          description: Full job detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1Detail" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Jobs]
      summary: Patch a job (all fields optional)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobV1UpdateRequest" }
      responses:
        "200":
          description: Updated job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1Detail" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Jobs]
      summary: Deactivate (soft-close) a job
      responses:
        "204": { description: Deactivated. Existing applications preserved. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/jobs/{jobId}/pageview-stats:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: integer }
      - in: query
        name: days
        schema: { type: integer, default: 30, minimum: 1, maximum: 365 }
        description: Window size, clamped to 1–365. Defaults to 30.
    get:
      tags: [Jobs]
      summary: Page-view analytics — referrers, geo, UTM, search-query terms
      description: |
        Free. Returns the same shape the recruiter portal renders on the
        per-job analytics dashboard: totals (last 24h / 7d / 30d), daily
        buckets, internal-vs-external split, top countries / cities /
        referrers / UTM campaigns / UTM sources / UTM mediums /
        device-types / search-query terms (what people Googled to find
        this job).

        Designed for AI assistants to spot patterns and suggest
        improvements — e.g. "LinkedIn drives 60% of views but only 30%
        of applications" or "top search query is 'manchester engineer'
        but the role is London — consider mentioning hybrid policy".
      responses:
        "200":
          description: Per-job page-view stats for the window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1PageViewStats" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/jobs/{jobId}/application-stats:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: integer }
      - in: query
        name: days
        schema: { type: integer, default: 30, minimum: 1, maximum: 365 }
    get:
      tags: [Jobs]
      summary: Application analytics — counts by day, source, geography, applicant seniority
      description: |
        Free. Total applications + daily buckets + conversion rate
        (applications ÷ views) + breakdowns by applicant address country,
        nationality, IP country, applicant city, applicant seniority (from
        their most recent EmploymentHistory title) and application source
        (the UTM source tag captured at submit time).

        Pairs with page-view-stats for AI funnel analysis: high views,
        low applications → description or filter issue; low views, high
        applications → posting reach is the bottleneck.
      responses:
        "200":
          description: Per-job application stats for the window.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1ApplicationStats" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/jobs/{jobId}/stage-funnel:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: integer }
      - in: query
        name: days
        schema: { type: integer, default: 90, minimum: 1, maximum: 365 }
        description: Window size, clamped to 1–365. Defaults to 90 (funnel windows are typically broader than page-view windows).
    get:
      tags: [Jobs]
      summary: Stage-conversion funnel for one job — counts applications ever reached each stage
      description: |
        Free. Returns one row per pipeline stage (New / Screened /
        Interviewed / Offered / Hired) with the count of applications
        that **ever reached** that stage, plus the per-stage conversion
        rate from the previous stage. Built from the
        `JobApplicationStatusHistory` audit so the counts are
        historically accurate even when current status has moved on.

        For AI use: spot big drops between stages (e.g. 70% of Screened
        never make it to Interviewing → screener wording problem) and
        the `drops` counter for rejected / withdrawn paths.
      responses:
        "200":
          description: Per-job stage funnel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StageFunnelV1Response" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/employer/stage-funnel:
    parameters:
      - in: query
        name: days
        schema: { type: integer, default: 90, minimum: 1, maximum: 365 }
    get:
      tags: [Jobs]
      summary: Employer-wide stage-conversion funnel across every job
      description: |
        Same shape as the per-job funnel but aggregated across every
        application owned by the calling employer in the window. Drives
        the "where am I losing candidates overall?" question.
      responses:
        "200":
          description: Employer-wide stage funnel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StageFunnelV1Response" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/employer/metrics:
    get:
      tags: [Jobs]
      summary: Aggregate counters across the employer's full hiring pipeline
      description: |
        Single rollup call that answers "how's my hiring going overall?"
        without per-job round-trips. Includes jobs by state, applications
        by stage + by source, last-30d activity, reach (page views +
        search impressions), and average applications per active job.
      responses:
        "200":
          description: Employer-wide rollup.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmployerMetricsV1Response" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/jobs/{id}/publish:
    parameters:
      - $ref: "#/components/parameters/jobId"
    post:
      tags: [Jobs]
      summary: Publish a draft job (consumes 1 JobPosting credit)
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expiryDate: { type: string, format: date-time }
      responses:
        "200":
          description: Now Active.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobV1Detail" }
        "400":
          description: Job is not in Draft state.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "402":
          description: Insufficient credits.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Applications ─────────────────────────────────────────────────

  /api/v1/jobs/{jobId}/applications:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: integer }
    get:
      tags: [Applications]
      summary: List applications for a job
      parameters:
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [New, Screened, Shortlisted, Interviewing, Interviewed, Offered, Hired, Rejected, Withdrawn]
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/pageSize"
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApplicationV1ListResponse" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/applications/{id}:
    parameters:
      - $ref: "#/components/parameters/applicationId"
    get:
      tags: [Applications]
      summary: Get one application (incl. candidate contact + screener answers)
      responses:
        "200":
          description: Detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApplicationV1Detail" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/applications/{id}/ai-assessment:
    parameters:
      - $ref: "#/components/parameters/applicationId"
    get:
      tags: [Applications]
      summary: AI suitability assessment (read-only projection)
      description: |
        Returns the persisted AI rating. Never re-runs the model from
        this endpoint — scoring is portal-only to keep AI credit spend
        explicit and auditable.
      responses:
        "200":
          description: Persisted assessment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApplicationV1AiAssessment" }
        "404":
          description: Either the application doesn't belong to your employer or no assessment has been computed yet.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/v1/applications/{id}/status:
    parameters:
      - $ref: "#/components/parameters/applicationId"
    post:
      tags: [Applications]
      summary: Move application to a new stage
      description: Writes an append-only audit row (`JobApplicationStatusHistory`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApplicationV1StatusRequest" }
      responses:
        "200":
          description: Transition recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApplicationV1StatusResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Tags ─────────────────────────────────────────────────────────

  /api/v1/tags:
    get:
      tags: [Tags]
      summary: List the employer's tag dictionary (with usage counts)
      responses:
        "200":
          description: All tags.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TagV1Summary" }

  /api/v1/applications/{id}/tags:
    parameters:
      - $ref: "#/components/parameters/applicationId"
    get:
      tags: [Tags]
      summary: Tags assigned to the candidate behind this application
      responses:
        "200":
          description: Assignments.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApplicationV1Tag" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Tags]
      summary: Assign a tag (auto-creates the tag on first use; case-folded)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagV1AssignRequest" }
      responses:
        "200":
          description: Assignment record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TagV1AssignResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/applications/{id}/tags/{label}:
    parameters:
      - $ref: "#/components/parameters/applicationId"
      - in: path
        name: label
        required: true
        schema: { type: string }
        description: Case-insensitive — "Hot" and "HOT" resolve to the same tag.
    delete:
      tags: [Tags]
      summary: Unassign a tag from the candidate
      responses:
        "204": { description: Unassigned. }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Notes ────────────────────────────────────────────────────────

  /api/v1/applications/{id}/notes:
    parameters:
      - $ref: "#/components/parameters/applicationId"
    get:
      tags: [Notes]
      summary: List recruiter notes (application + candidate-level)
      responses:
        "200":
          description: Notes.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApplicationV1Note" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Notes]
      summary: Create a note (attributed to the key's user)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NoteV1CreateRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties: { id: { type: integer } }
        "403":
          description: Key has no associated user — notes can't be attributed. Re-mint from your account.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/notes/{noteId}:
    parameters:
      - in: path
        name: noteId
        required: true
        schema: { type: integer }
    put:
      tags: [Notes]
      summary: Edit a note (author only)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NoteV1UpdateRequest" }
      responses:
        "204": { description: Updated. }
        "403":
          description: Not the original author.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Notes]
      summary: Soft-delete a note (author only)
      responses:
        "204": { description: Deleted. }
        "403":
          description: Not the original author.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Scorecards ───────────────────────────────────────────────────

  /api/v1/applications/{id}/scorecards:
    parameters:
      - $ref: "#/components/parameters/applicationId"
    get:
      tags: [Scorecards]
      summary: List interview scorecards on this application
      responses:
        "200":
          description: Scorecards.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ApplicationV1Scorecard" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/applications/{id}/scorecards/{stage}:
    parameters:
      - $ref: "#/components/parameters/applicationId"
      - in: path
        name: stage
        required: true
        schema:
          type: string
          enum: [New, Screened, Shortlisted, Interviewing, Interviewed, Offered, Hired, Rejected, Withdrawn]
    put:
      tags: [Scorecards]
      summary: Upsert this user's scorecard for the given stage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScorecardV1UpsertRequest" }
      responses:
        "204": { description: Upserted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403":
          description: Key has no associated user — scorecards can't be attributed.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Scorecards]
      summary: Delete this user's scorecard for the given stage
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Candidate-self (Me) ──────────────────────────────────────────

  /api/v1/me/profile:
    get:
      tags: [Me]
      summary: Read your own profile (incl. skills, employment, education, preferences)
      security:
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      responses:
        "200":
          description: Full profile bundle.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeV1Profile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Me]
      summary: Patch your own profile (all fields optional; null fields untouched)
      security:
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MeV1ProfileUpdateRequest" }
      responses:
        "200":
          description: Updated profile (full bundle, same shape as GET).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeV1Profile" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/me/applications:
    get:
      tags: [Me]
      summary: List your own applications (paginated, filterable by status)
      security:
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      parameters:
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [New, Screened, Shortlisted, Interviewing, Interviewed, Offered, Hired, Rejected, Withdrawn]
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/pageSize"
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeV1ApplicationListResponse" }
    post:
      tags: [Me]
      summary: Apply to one job on your behalf (AI-assisted)
      description: |
        Phase 1C safeguards: exactly one `jobId` per call, `confirm: true`
        required (the MCP tool description spells out "confirm with the
        candidate first" — a missing flag returns 400), 10 successful
        applies per UTC day per candidate (enforced across every key),
        same consent-freshness and screening-question gates as the portal
        apply. Successful submissions land in the recruiter UI with an
        "AI-assisted" badge so the recruiter can weight the application
        accordingly. Requires the `Apply` scope.
      security:
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MeV1ApplyRequest" }
      responses:
        "201":
          description: Application created. Body includes `isAiAssisted=true` and a daily-counter snapshot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeV1ApplyResponse" }
        "400":
          description: |
            One of: `confirmation_required` (missing `confirm`), `job_not_active`,
            `unknown_question` (a supplied screening-answer id doesn't belong to
            this job), `screening_required` (a required screening question is
            unanswered).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: |
            One of: `unattributable` (key has no associated user — re-mint),
            `consent_not_current` (the candidate's DataProcessing consent has
            lapsed; they need to refresh it in the portal first), or scope
            missing (`Apply` not granted).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Profile or job not found.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "409":
          description: `already_applied` — the candidate has previously applied to this job (from any surface).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: |
            `daily_limit` — 10 AI-assisted applies per UTC day per candidate
            already used. Response carries a `Retry-After` header pointing
            at the next-available slot. Apply via the portal in the meantime.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the oldest counted apply rolls off the 24-hour window.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/v1/me/applications/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    get:
      tags: [Me]
      summary: Read one of your own applications
      security:
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      responses:
        "200":
          description: Application detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeV1ApplicationDetail" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Candidates (CV search) ───────────────────────────────────────

  /api/v1/candidates/search:
    post:
      tags: [Candidates]
      summary: Boolean CV search (1 CV Search credit per call)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CandidateV1SearchRequest" }
      responses:
        "200":
          description: Masked search results (no contact details).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CandidateV1SearchResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402":
          description: Insufficient CV Search credits.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /api/v1/candidates/{id}/preview:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    get:
      tags: [Candidates]
      summary: Anonymous candidate preview — free, rate-limited per API key (200/hour)
      description: |
        Returns the candidate's anonymous-detail profile so a recruiter
        (or their AI assistant) can assess fit before deciding whether
        to spend a CV-Download credit on the full `GET /api/v1/candidates/{id}`
        unlock. Free, no unlock recorded, rate-limited per API-key
        creator at 200 calls/hour (429 with Retry-After when exceeded).

        **Held back from this surface** (require unlock to see):
        real first/family name, email, phones, photo URL, social URLs,
        CV file/text. The masked name follows the same shape as the
        search row.
      responses:
        "200":
          description: Anonymous-detail profile.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CandidateV1AnonymousPreview" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429":
          description: Rate limit exceeded. Honour the Retry-After header.

  /api/v1/candidates/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    get:
      tags: [Candidates]
      summary: Full candidate profile (1 CV Download credit on first call; free for 30 days after)
      responses:
        "200":
          description: Full profile.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CandidateV1Profile" }
        "402":
          description: Insufficient CV Download credits.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Auth (identity introspection) ───────────────────────────────────

  /api/v1/auth/whoami:
    get:
      tags: [Auth]
      summary: Resolve the calling credential to its principal + tenant host
      description: |
        Returns the principal kind (`employer` or `candidate`), the
        subject id (employer id or job-seeker id), the granted scopes,
        and the tenant's verified primary hostname + base URL. Accepts
        either authentication scheme — recruiter or candidate API key.

        Built so the MCP server (and other clients) can require only the
        API key, then resolve the per-tenant host with one call rather
        than asking the operator to configure base URL separately.
        `tenantHost` and `apiBaseUrl` are `null` when the tenant has no
        verified primary `TenantDomain` row yet — in that case the
        client should fall back to an explicitly-configured URL.
      security:
        - ApiKeyBearer: []
        - ApiKeyHeader: []
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      responses:
        "200":
          description: Identity resolved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WhoAmIV1Response" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Shortlist ───────────────────────────────────────────────────────

  /api/v1/shortlist:
    get:
      tags: [Shortlist]
      summary: List the employer's shortlist (most-recent first)
      responses:
        "200":
          description: Shortlist rows with embedded candidate display fields.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ShortlistV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Shortlist]
      summary: Bulk-add candidates to the shortlist (idempotent on duplicates, max 50 per call)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShortlistV1AddRequest" }
      responses:
        "200":
          description: Counts for added / duplicate / unknown ids.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShortlistV1AddResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/shortlist/{jobSeekerId}:
    parameters:
      - in: path
        name: jobSeekerId
        required: true
        schema: { type: integer }
        description: JobSeeker id, NOT the shortlist row id (matches the value the UI carries from search results).
    delete:
      tags: [Shortlist]
      summary: Remove a candidate from the shortlist
      responses:
        "204": { description: Removed. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Employer (helpers) ──────────────────────────────────────────────

  /api/v1/employer/users:
    get:
      tags: [Employer]
      summary: List same-tenant employer-side users (id + email + role)
      description: |
        Read-only discovery helper. Used by MCP/CLI clients to resolve
        a colleague's id by email before raising a review request.
        Returns only active users; disabled rows are filtered out.
      responses:
        "200":
          description: Active users on the caller's employer.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/EmployerUserV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Review requests ─────────────────────────────────────────────────

  /api/v1/applications/{applicationId}/review-requests:
    parameters:
      - in: path
        name: applicationId
        required: true
        schema: { type: integer }
    get:
      tags: [ReviewRequests]
      summary: List review requests raised against one application
      responses:
        "200":
          description: Review requests for the application, most-recent first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ReviewRequestV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [ReviewRequests]
      summary: Raise a review request against an application (sends email to assignee)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewRequestV1CreateRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewRequestV1CreateResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/review-requests/mine:
    get:
      tags: [ReviewRequests]
      summary: List review requests assigned to the calling user (their queue)
      parameters:
        - in: query
          name: includeCompleted
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Review requests for the calling user.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ReviewRequestV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/review-requests/{id}/complete:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    post:
      tags: [ReviewRequests]
      summary: Complete a review request you've been assigned (assignee only)
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewRequestV1CompleteRequest" }
      responses:
        "204": { description: Marked complete. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Screening questions (recruiter library) ─────────────────────────

  /api/v1/screening-questions:
    get:
      tags: [ScreeningQuestions]
      summary: List the employer's question library + read-only system defaults
      responses:
        "200":
          description: Active (non-soft-deleted) library questions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ScreeningQuestionV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [ScreeningQuestions]
      summary: Create a question in the employer's library
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScreeningQuestionV1CreateRequest" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScreeningQuestionV1Row" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/screening-questions/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
    put:
      tags: [ScreeningQuestions]
      summary: Update one of the employer's own library questions (PATCH semantics on correctAnswer)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScreeningQuestionV1UpdateRequest" }
      responses:
        "204": { description: Updated. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [ScreeningQuestions]
      summary: Soft-delete a library question (preserves historical answers)
      responses:
        "204": { description: Soft-deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Screening questions (per-job attachment) ────────────────────────

  /api/v1/jobs/{jobId}/screening-questions:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: integer }
    get:
      tags: [ScreeningQuestions]
      summary: List the screening questions attached to one job
      responses:
        "200":
          description: Attached questions with per-job override + library default.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/JobScreeningQuestionV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [ScreeningQuestions]
      summary: Replace the entire question set on a job (atomic)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobScreeningQuestionsV1ReplaceRequest" }
      responses:
        "204": { description: Replaced. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Screening answers (recruiter read) ──────────────────────────────

  /api/v1/applications/{applicationId}/screening-answers:
    parameters:
      - in: path
        name: applicationId
        required: true
        schema: { type: integer }
    get:
      tags: [ScreeningQuestions]
      summary: Read the candidate's answers for one application (recruiter)
      description: |
        Returns one row per recorded answer with the question text +
        the recruiter's canonical `correctAnswer` alongside the
        candidate's `answerText`. Scope `Applications`.
      responses:
        "200":
          description: Recorded answers.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ScreeningAnswerV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Screening questions (candidate-side read) ───────────────────────

  /api/v1/me/jobs/{jobId}/screening-questions:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: integer }
    get:
      tags: [ScreeningQuestions, Me]
      summary: Read the screening questions attached to a job (candidate-side)
      description: |
        Lets a candidate (or their AI assistant) preview questions
        before composing answers. NEVER returns the recruiter's
        `correctAnswer` or per-job override — that's the safety
        guarantee distinguishing this from the recruiter read.
        Scope `Profile`.
      security:
        - CandidateApiKeyBearer: []
        - CandidateApiKeyHeader: []
      responses:
        "200":
          description: Candidate-safe question shape.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CandidateScreeningQuestionV1Row" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Sales surface (Sales/Admin read-only via sales:read OAuth) ──────
  # Backfill stub block — adds path/method parity for
  # SalesApiV1Controller endpoints that shipped without
  # documentation. Replace each block with a full schema +
  # description in a follow-up pass; the parity guard only
  # asserts path + method here.

  /api/v1/sales/dashboard:
    get:
      tags: [Sales]
      summary: Sales dashboard rollup
      responses:
        "200": { description: Dashboard rollup. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/sales/employers:
    get:
      tags: [Sales]
      summary: List employers with filters
      responses:
        "200": { description: Paged employer list. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/sales/employers/managers:
    get:
      tags: [Sales]
      summary: List potential account managers
      responses:
        "200": { description: Manager list. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/sales/employers/{employerId}:
    get:
      tags: [Sales]
      summary: Employer detail
      responses:
        "200": { description: Detail. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/sales/employers/{employerId}/activity/applications:
    get:
      tags: [Sales]
      summary: Employer application-activity feed
      responses:
        "200": { description: Activity list. }

  /api/v1/sales/employers/{employerId}/activity/feed:
    get:
      tags: [Sales]
      summary: Employer unified activity feed
      responses:
        "200": { description: Activity list. }

  /api/v1/sales/employers/{employerId}/activity/invoices:
    get:
      tags: [Sales]
      summary: Employer invoice activity
      responses:
        "200": { description: Invoice activity list. }

  /api/v1/sales/employers/{employerId}/activity/jobs:
    get:
      tags: [Sales]
      summary: Employer job-posting activity
      responses:
        "200": { description: Activity list. }

  /api/v1/sales/employers/{employerId}/activity/messages:
    get:
      tags: [Sales]
      summary: Employer messaging activity
      responses:
        "200": { description: Activity list. }

  /api/v1/sales/employers/{employerId}/contracts:
    get:
      tags: [Sales]
      summary: Employer contracts
      responses:
        "200": { description: Contract list. }

  /api/v1/sales/employers/{employerId}/credits:
    get:
      tags: [Sales]
      summary: Employer credit balances
      responses:
        "200": { description: Credit balances. }

  /api/v1/sales/employers/{employerId}/credits/cv-audit:
    get:
      tags: [Sales]
      summary: CV-audit credit activity
      responses:
        "200": { description: CV-audit credit activity. }

  /api/v1/sales/my-accounts:
    get:
      tags: [Sales]
      summary: Accounts owned by the calling sales rep
      responses:
        "200": { description: Account list. }

  /api/v1/sales/commission:
    get:
      tags: [Sales]
      summary: Commission report
      responses:
        "200": { description: Commission report. }

  /api/v1/sales/contracts/recently-expired:
    get:
      tags: [Sales]
      summary: Recently expired contracts (renewal pipeline)
      responses:
        "200": { description: Expired contract list. }

  /api/v1/sales/renewals:
    get:
      tags: [Sales]
      summary: Upcoming contract renewals
      responses:
        "200": { description: Renewal list. }

components:

  securitySchemes:
    ApiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: pk_live_...
      description: Send the recruiter API key as a Bearer token.
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Alternative — send the recruiter key in `X-Api-Key`.
    CandidateApiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: pk_live_...
      description: |
        Candidate-issued key, used by AI assistants and the candidate CLI.
        Validates against the separate `CandidateApiKeys` table — same
        prefix as recruiter keys but mint-able only from
        `/candidate/api-keys` and only ever resolves to the owning
        candidate's data.
    CandidateApiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Alternative — send the candidate key in `X-Api-Key`.

  parameters:
    page:
      in: query
      name: page
      schema: { type: integer, default: 1, minimum: 1 }
      required: false
    pageSize:
      in: query
      name: pageSize
      schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      required: false
    jobId:
      in: path
      name: id
      required: true
      schema: { type: integer }
    applicationId:
      in: path
      name: id
      required: true
      schema: { type: integer }

  responses:
    Unauthorized:
      description: API key missing, revoked, expired, or unknown.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Key valid but doesn't carry the required scope.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found, or owned by a different employer.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BadRequest:
      description: Request validation failed.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:

    Error:
      type: object
      required: [code, message]
      properties:
        code:    { type: string, example: not_found }
        message: { type: string, example: Application not found. }

    JobV1Summary:
      type: object
      properties:
        id:                 { type: integer }
        title:              { type: string }
        reference:          { type: string, nullable: true }
        status:             { type: string, enum: [Draft, Active, Inactive, Expired] }
        city:               { type: string, nullable: true }
        state:              { type: string, nullable: true }
        country:            { type: string, nullable: true }
        employmentType:     { type: string, nullable: true }
        remoteType:         { type: string, nullable: true }
        categoryName:       { type: string, nullable: true }
        salaryMin:          { type: number, format: float, nullable: true }
        salaryMax:          { type: number, format: float, nullable: true }
        salaryCurrency:     { type: string, nullable: true }
        salaryPeriod:       { type: string, nullable: true }
        createdAt:          { type: string, format: date-time }
        postedDate:         { type: string, format: date-time, nullable: true }
        expiryDate:         { type: string, format: date-time, nullable: true }

    JobV1Detail:
      allOf:
        - $ref: "#/components/schemas/JobV1Summary"
        - type: object
          properties:
            description:       { type: string }
            categoryId:        { type: integer, nullable: true }
            applyMethod:       { type: string, nullable: true }
            applyEmailAddress: { type: string, nullable: true, format: email }
            applyDirectUrl:    { type: string, nullable: true, format: uri }
            ownerUserId:       { type: integer, nullable: true }
            updatedAt:         { type: string, format: date-time, nullable: true }

    JobV1ListResponse:
      type: object
      required: [total, page, pageSize, items]
      properties:
        total:    { type: integer }
        page:     { type: integer }
        pageSize: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/JobV1Summary" }

    JobV1CreateRequest:
      type: object
      required: [title, description, country, employmentType]
      properties:
        title:             { type: string, minLength: 3, maxLength: 200 }
        reference:         { type: string, maxLength: 64, nullable: true }
        description:       { type: string, minLength: 50, maxLength: 50000 }
        city:              { type: string, maxLength: 120, nullable: true }
        state:             { type: string, maxLength: 120, nullable: true }
        country:           { type: string, maxLength: 80 }
        employmentType:    { type: string, maxLength: 20 }
        remoteType:        { type: string, maxLength: 20, nullable: true }
        categoryId:        { type: integer, nullable: true }
        salaryMin:         { type: number, format: float, nullable: true }
        salaryMax:         { type: number, format: float, nullable: true }
        salaryCurrency:    { type: string, maxLength: 3, nullable: true }
        salaryPeriod:      { type: string, maxLength: 20, nullable: true }
        applyMethod:       { type: string, maxLength: 20, nullable: true }
        applyEmailAddress: { type: string, format: email, maxLength: 254, nullable: true }
        applyDirectUrl:    { type: string, format: uri, maxLength: 2048, nullable: true }

    JobV1UpdateRequest:
      description: All fields optional; null fields left untouched.
      type: object
      properties:
        title:             { type: string, minLength: 3, maxLength: 200, nullable: true }
        reference:         { type: string, maxLength: 64, nullable: true }
        description:       { type: string, minLength: 50, maxLength: 50000, nullable: true }
        city:              { type: string, maxLength: 120, nullable: true }
        state:             { type: string, maxLength: 120, nullable: true }
        country:           { type: string, maxLength: 80, nullable: true }
        employmentType:    { type: string, maxLength: 20, nullable: true }
        remoteType:        { type: string, maxLength: 20, nullable: true }
        categoryId:        { type: integer, nullable: true }
        salaryMin:         { type: number, format: float, nullable: true }
        salaryMax:         { type: number, format: float, nullable: true }
        salaryCurrency:    { type: string, maxLength: 3, nullable: true }
        salaryPeriod:      { type: string, maxLength: 20, nullable: true }
        applyMethod:       { type: string, maxLength: 20, nullable: true }
        applyEmailAddress: { type: string, format: email, maxLength: 254, nullable: true }
        applyDirectUrl:    { type: string, format: uri, maxLength: 2048, nullable: true }

    ApplicationV1Summary:
      type: object
      properties:
        id:                  { type: integer }
        jobId:               { type: integer }
        jobTitle:            { type: string }
        jobReference:        { type: string, nullable: true }
        candidateId:         { type: integer }
        candidateFirstName:  { type: string }
        candidateFamilyName: { type: string }
        candidateHeadline:   { type: string, nullable: true }
        candidateCity:       { type: string, nullable: true }
        status:              { type: string }
        appliedAt:           { type: string, format: date-time }
        updatedAt:           { type: string, format: date-time }

    ApplicationV1Detail:
      allOf:
        - $ref: "#/components/schemas/ApplicationV1Summary"
        - type: object
          properties:
            candidateEmail:        { type: string, nullable: true }
            candidateSummary:      { type: string, nullable: true }
            candidateState:        { type: string, nullable: true }
            candidateCountry:      { type: string, nullable: true }
            candidatePhone:        { type: string, nullable: true }
            candidateMobilePhone:  { type: string, nullable: true }
            candidateLinkedInUrl:  { type: string, nullable: true }
            candidateGitHubUrl:    { type: string, nullable: true }
            candidateTwitterUrl:   { type: string, nullable: true }
            candidateWebsiteUrl:   { type: string, nullable: true }
            applicationType:       { type: string }
            coverLetterBody:       { type: string, nullable: true }
            externalResumeUrl:     { type: string, nullable: true, format: uri }
            screenerAnswers:
              type: array
              items: { $ref: "#/components/schemas/ApplicationV1ScreenerAnswer" }

    ApplicationV1ScreenerAnswer:
      type: object
      properties:
        question:        { type: string }
        answer:          { type: string }
        preferredAnswer: { type: string, nullable: true }

    ApplicationV1ListResponse:
      type: object
      required: [total, page, pageSize, items]
      properties:
        total:    { type: integer }
        page:     { type: integer }
        pageSize: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/ApplicationV1Summary" }

    ApplicationV1AiAssessment:
      type: object
      properties:
        applicationId:    { type: integer }
        score:            { type: integer, minimum: 0, maximum: 100 }
        verdict:          { type: string, example: strong }
        summary:          { type: string }
        model:            { type: string, nullable: true }
        ratedAt:          { type: string, format: date-time }
        isAutoRejected:   { type: boolean, nullable: true }
        autoRejectReason: { type: string, nullable: true }
        rubricScores:
          nullable: true
          type: array
          items: { $ref: "#/components/schemas/ApplicationV1RubricScore" }

    ApplicationV1RubricScore:
      type: object
      properties:
        rubricId:    { type: integer }
        name:        { type: string }
        weight:      { type: integer }
        score:       { type: integer, nullable: true }
        band:        { type: string, nullable: true }
        isMustHave:  { type: boolean }

    ApplicationV1StatusRequest:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [New, Screened, Shortlisted, Interviewing, Interviewed, Offered, Hired, Rejected, Withdrawn]
        note:
          type: string
          maxLength: 500
          nullable: true

    ApplicationV1StatusResponse:
      type: object
      properties:
        id:     { type: integer }
        status: { type: string }

    TagV1Summary:
      type: object
      properties:
        id:         { type: integer }
        label:      { type: string }
        usageCount: { type: integer }

    ApplicationV1Tag:
      type: object
      properties:
        id:         { type: integer, description: Assignment id (use with DELETE) }
        tagId:      { type: integer }
        label:      { type: string }
        assignedAt: { type: string, format: date-time }

    TagV1AssignRequest:
      type: object
      required: [label]
      properties:
        label: { type: string, minLength: 1, maxLength: 40 }

    TagV1AssignResponse:
      type: object
      properties:
        assignmentId: { type: integer }
        tagId:        { type: integer }
        label:        { type: string }

    ApplicationV1Note:
      type: object
      properties:
        id:            { type: integer }
        candidateId:   { type: integer }
        applicationId: { type: integer, nullable: true }
        body:          { type: string }
        createdAt:     { type: string, format: date-time }
        updatedAt:     { type: string, format: date-time }
        authorUserId:  { type: integer }

    NoteV1CreateRequest:
      type: object
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 4000 }

    NoteV1UpdateRequest:
      type: object
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 4000 }

    ApplicationV1Scorecard:
      type: object
      properties:
        id:             { type: integer }
        applicationId:  { type: integer }
        authorUserId:   { type: integer }
        stage:          { type: string }
        recommendation: { type: string, enum: [StrongNo, No, Hold, Yes, StrongYes] }
        rationale:      { type: string, nullable: true }
        createdAt:      { type: string, format: date-time }
        updatedAt:      { type: string, format: date-time }

    ScorecardV1UpsertRequest:
      type: object
      required: [recommendation]
      properties:
        recommendation: { type: string, enum: [StrongNo, No, Hold, Yes, StrongYes] }
        rationale:      { type: string, maxLength: 1000, nullable: true }

    CandidateV1SearchRequest:
      type: object
      properties:
        query:                 { type: string, maxLength: 1000, nullable: true }
        nationalities:         { type: array, items: { type: string } }
        languages:             { type: array, items: { type: string } }
        sectorCodes:           { type: array, items: { type: integer } }
        preferredCountryIds:   { type: array, items: { type: integer } }
        minQualificationLevel: { type: integer, minimum: 0, maximum: 10, nullable: true }
        minSalary:             { type: number, format: float, nullable: true }
        maxSalary:             { type: number, format: float, nullable: true }
        salaryCurrency:        { type: string, maxLength: 3, nullable: true }
        activeWithinDays:      { type: integer, minimum: 1, maximum: 3650, nullable: true }
        pageNumber:            { type: integer, minimum: 1, nullable: true }
        pageSize:              { type: integer, minimum: 1, maximum: 100, nullable: true }

    CandidateV1SearchResult:
      type: object
      properties:
        total:    { type: integer }
        page:     { type: integer }
        pageSize: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/CandidateV1Match" }

    CandidateV1Match:
      type: object
      properties:
        id:            { type: integer }
        maskedName:    { type: string, example: "P*** E***" }
        headline:      { type: string, nullable: true }
        city:          { type: string, nullable: true }
        country:       { type: string, nullable: true }
        skills:        { type: array, items: { type: string } }
        sectorCodes:   { type: array, items: { type: integer } }
        nationalities: { type: array, items: { type: string } }
        languages:     { type: array, items: { type: string } }
        lastActiveAt:  { type: string, format: date-time, nullable: true }
        isUnlocked:    { type: boolean }

    MeV1Profile:
      type: object
      properties:
        id:                { type: integer }
        email:             { type: string, format: email }
        firstName:         { type: string }
        middleNames:       { type: string }
        familyName:        { type: string }
        fullName:          { type: string }
        headline:          { type: string, nullable: true }
        summary:           { type: string, nullable: true }
        photoUrl:          { type: string, nullable: true, format: uri }
        phone:             { type: string, nullable: true }
        mobilePhone:       { type: string, nullable: true }
        linkedInUrl:       { type: string, nullable: true, format: uri }
        gitHubUrl:         { type: string, nullable: true, format: uri }
        twitterUrl:        { type: string, nullable: true, format: uri }
        websiteUrl:        { type: string, nullable: true, format: uri }
        addressLine1:      { type: string }
        addressLine2:      { type: string, nullable: true }
        city:              { type: string }
        state:             { type: string }
        postalCode:        { type: string, nullable: true }
        country:           { type: string, nullable: true }
        drivingLicence:    { type: string, nullable: true }
        yearsOfExperience: { type: integer, nullable: true }
        visibility:        { type: string, enum: [Hidden, AgenciesOnly, FullyVisible] }
        preferences:       { $ref: "#/components/schemas/MeV1Preferences" }
        skills:            { type: array, items: { type: string } }
        languages:         { type: array, items: { type: string } }
        nationalities:     { type: array, items: { type: string } }
        employment:        { type: array, items: { $ref: "#/components/schemas/MeV1Employment" } }
        education:         { type: array, items: { $ref: "#/components/schemas/MeV1Education" } }
        completeness:      { type: integer, minimum: 0, maximum: 100 }

    MeV1Preferences:
      type: object
      properties:
        desiredJobTitle:        { type: string, nullable: true }
        careerStatus:           { type: string }
        availableFrom:          { type: string, format: date-time, nullable: true }
        noticePeriodValue:      { type: integer, nullable: true }
        noticePeriodUnit:       { type: string, nullable: true, enum: [Weeks, Months] }
        expectedSalaryMin:      { type: number, format: float, nullable: true }
        expectedSalaryMax:      { type: number, format: float, nullable: true }
        expectedSalaryCurrency: { type: string, nullable: true }
        expectedSalaryPeriod:   { type: string, nullable: true }
        willRelocate:           { type: boolean }
        jobTypePreferences:     { type: array, items: { type: string } }

    MeV1Employment:
      type: object
      properties:
        id:           { type: integer }
        jobTitle:     { type: string }
        employerName: { type: string }
        startDate:    { type: string, format: date-time, nullable: true }
        endDate:      { type: string, format: date-time, nullable: true }
        isCurrent:    { type: boolean }
        summary:      { type: string, nullable: true }

    MeV1Education:
      type: object
      properties:
        id:            { type: integer }
        institution:   { type: string }
        qualification: { type: string, nullable: true }
        fieldOfStudy:  { type: string, nullable: true }
        startDate:     { type: string, format: date-time, nullable: true }
        endDate:       { type: string, format: date-time, nullable: true }

    MeV1ProfileUpdateRequest:
      description: All fields optional. Null = leave alone; empty string clears.
      type: object
      properties:
        firstName:              { type: string, maxLength: 100, nullable: true }
        middleNames:            { type: string, maxLength: 200, nullable: true }
        familyName:             { type: string, maxLength: 100, nullable: true }
        headline:               { type: string, maxLength: 200, nullable: true }
        summary:                { type: string, maxLength: 2000, nullable: true }
        photoUrl:               { type: string, maxLength: 500, nullable: true }
        phone:                  { type: string, maxLength: 40, nullable: true }
        mobilePhone:            { type: string, maxLength: 40, nullable: true }
        linkedInUrl:            { type: string, maxLength: 500, nullable: true }
        gitHubUrl:              { type: string, maxLength: 500, nullable: true }
        twitterUrl:             { type: string, maxLength: 500, nullable: true }
        websiteUrl:             { type: string, maxLength: 500, nullable: true }
        addressLine1:           { type: string, maxLength: 200, nullable: true }
        addressLine2:           { type: string, maxLength: 200, nullable: true }
        city:                   { type: string, maxLength: 100, nullable: true }
        state:                  { type: string, maxLength: 100, nullable: true }
        postalCode:             { type: string, maxLength: 20, nullable: true }
        country:                { type: string, maxLength: 100, nullable: true }
        drivingLicence:         { type: string, maxLength: 200, nullable: true }
        yearsOfExperience:      { type: integer, minimum: 0, maximum: 70, nullable: true }
        desiredJobTitle:        { type: string, maxLength: 200, nullable: true }
        careerStatus:           { type: string, enum: [ActivelyLooking, OpenToOffers, NotLooking, InEducation], nullable: true }
        availableFrom:          { type: string, format: date-time, nullable: true }
        noticePeriodValue:      { type: integer, minimum: 1, maximum: 8, nullable: true }
        noticePeriodUnit:       { type: string, enum: [Weeks, Months], nullable: true }
        expectedSalaryMin:      { type: number, format: float, nullable: true }
        expectedSalaryMax:      { type: number, format: float, nullable: true }
        expectedSalaryCurrency: { type: string, maxLength: 3, nullable: true }
        expectedSalaryPeriod:   { type: string, maxLength: 10, nullable: true }
        willRelocate:           { type: boolean, nullable: true }
        jobTypePreferences:     { type: array, items: { type: string }, nullable: true }

    MeV1ApplicationSummary:
      type: object
      properties:
        id:           { type: integer }
        jobId:        { type: integer }
        jobTitle:     { type: string }
        employerName: { type: string }
        location:     { type: string, nullable: true }
        status:       { type: string }
        appliedAt:    { type: string, format: date-time }
        updatedAt:    { type: string, format: date-time }

    MeV1ApplicationDetail:
      type: object
      properties:
        id:                { type: integer }
        jobId:             { type: integer }
        jobTitle:          { type: string }
        employerName:      { type: string }
        jobReference:      { type: string, nullable: true }
        jobCity:           { type: string, nullable: true }
        jobCountry:        { type: string, nullable: true }
        status:            { type: string }
        appliedAt:         { type: string, format: date-time }
        updatedAt:         { type: string, format: date-time }
        applicationType:   { type: string }
        coverLetterBody:   { type: string, nullable: true }
        externalResumeUrl: { type: string, nullable: true, format: uri }

    MeV1ApplyRequest:
      type: object
      required: [jobId, confirm]
      properties:
        jobId:    { type: integer }
        confirm:
          type: boolean
          description: Must be `true`. Safeguard against AI assistants firing the call without the candidate's explicit say-so.
        coverLetter:
          type: string
          maxLength: 10000
          nullable: true
        screeningAnswers:
          type: array
          items: { $ref: "#/components/schemas/MeV1ScreeningAnswer" }
          nullable: true

    MeV1ScreeningAnswer:
      type: object
      required: [questionId, answerText]
      properties:
        questionId: { type: integer }
        answerText: { type: string, maxLength: 2000 }

    MeV1ApplyResponse:
      type: object
      properties:
        id:                  { type: integer }
        jobId:               { type: integer }
        jobTitle:            { type: string }
        employerName:        { type: string }
        status:              { type: string }
        appliedAt:           { type: string, format: date-time }
        isAiAssisted:        { type: boolean }
        aiAppliesUsedToday:  { type: integer, description: Applies counted in the last 24 hours, including this one. }
        aiAppliesLimitPerDay: { type: integer, description: Platform-wide cap (10). }

    MeV1ApplicationListResponse:
      type: object
      properties:
        total:    { type: integer }
        page:     { type: integer }
        pageSize: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/MeV1ApplicationSummary" }

    CandidateV1Profile:
      type: object
      properties:
        id:                     { type: integer }
        fullName:               { type: string }
        firstName:              { type: string, nullable: true }
        familyName:             { type: string, nullable: true }
        headline:               { type: string, nullable: true }
        summary:                { type: string, nullable: true }
        photoUrl:               { type: string, nullable: true, format: uri }
        email:                  { type: string, format: email }
        phone:                  { type: string, nullable: true }
        mobilePhone:            { type: string, nullable: true }
        linkedInUrl:            { type: string, nullable: true, format: uri }
        gitHubUrl:              { type: string, nullable: true, format: uri }
        twitterUrl:             { type: string, nullable: true, format: uri }
        websiteUrl:             { type: string, nullable: true, format: uri }
        city:                   { type: string, nullable: true }
        state:                  { type: string, nullable: true }
        country:                { type: string, nullable: true }
        desiredJobTitle:        { type: string, nullable: true }
        careerStatus:           { type: string, nullable: true }
        availableFrom:          { type: string, format: date-time, nullable: true }
        noticePeriodValue:      { type: integer, nullable: true }
        noticePeriodUnit:       { type: string, nullable: true }
        drivingLicence:         { type: string, nullable: true }
        yearsOfExperience:      { type: integer, nullable: true }
        willRelocate:           { type: boolean }
        expectedSalaryMin:      { type: number, format: float, nullable: true }
        expectedSalaryMax:      { type: number, format: float, nullable: true }
        expectedSalaryCurrency: { type: string, nullable: true }
        expectedSalaryPeriod:   { type: string, nullable: true }
        maxQualificationLevel:  { type: integer, nullable: true }
        skills:                 { type: array, items: { type: string } }
        sectorCodes:            { type: array, items: { type: integer } }
        nationalities:          { type: array, items: { type: string } }
        languages:              { type: array, items: { type: string } }
        lastActiveAt:           { type: string, format: date-time, nullable: true }
        unlockExpiresAt:        { type: string, format: date-time }

    WhoAmIV1Response:
      type: object
      required: [principal, subjectId, scopes, key]
      properties:
        principal:
          type: string
          enum: [employer, candidate]
          description: Which scheme authenticated the request.
        subjectId:
          type: integer
          description: Employer id for `employer`, JobSeeker id for `candidate`.
        userId:
          type: integer
          nullable: true
          description: |
            The owning User row. For `employer` this is the key's
            CreatedByUserId (null for the rare sales-side-minted key).
            For `candidate` always set (every key has an owning user).
        scopes:
          type: array
          items: { type: string }
          description: |
            String names of the granted scopes. Recruiter values:
            `JobPosting`, `Applications`, `CvSearch`. Candidate values:
            `Profile`, `Applications`, `Apply`.
        tenantId:
          type: integer
          nullable: true
        tenantHost:
          type: string
          nullable: true
          description: Verified primary hostname of the tenant; null if none configured.
        apiBaseUrl:
          type: string
          format: uri
          nullable: true
          description: |
            `https://{tenantHost}` when `tenantHost` is set; null otherwise.
            Clients should adopt this as the base URL for subsequent calls
            (this is the point of the endpoint).
        key:
          $ref: "#/components/schemas/WhoAmIV1Key"

    WhoAmIV1Key:
      type: object
      required: [id, name, prefix]
      properties:
        id:     { type: integer }
        name:   { type: string, description: "Operator-supplied label, e.g. 'Bullhorn integration'." }
        prefix: { type: string, description: "First 12 chars of the plaintext key (e.g. pk_live_AbCd) — safe to log." }

    ShortlistV1Row:
      type: object
      required: [id, jobSeekerId, name, addedAt]
      properties:
        id:             { type: integer, description: "Shortlist row id." }
        jobSeekerId:    { type: integer }
        name:           { type: string, description: "Candidate's full name (FirstName + FamilyName)." }
        headline:       { type: string, nullable: true }
        city:           { type: string, nullable: true }
        country:        { type: string, nullable: true, description: "Country name, not ISO code." }
        lastActiveAt:   { type: string, format: date-time, nullable: true }
        addedAt:        { type: string, format: date-time }
        addedByUserId:  { type: integer, nullable: true }
        addedByEmail:   { type: string, nullable: true, format: email }
        note:
          type: string
          nullable: true
          description: |
            Recruiter-internal note set at add-time. The MCP server's
            shortlist projection strips this field before handing
            snapshots to the model, but the v1 surface always returns
            it for direct API + CLI consumers.

    ShortlistV1AddRequest:
      type: object
      required: [jobSeekerIds]
      properties:
        jobSeekerIds:
          type: array
          items: { type: integer }
          minItems: 1
          maxItems: 50
        note:
          type: string
          maxLength: 500
          nullable: true
          description: Optional shared note applied to every row in this batch.

    ShortlistV1AddResponse:
      type: object
      required: [added, duplicates, unknown]
      properties:
        added:      { type: integer, description: "Newly inserted rows." }
        duplicates: { type: integer, description: "Already on the shortlist; no-op." }
        unknown:    { type: integer, description: "Ids that don't correspond to an active JobSeeker (includes soft-deleted)." }

    EmployerUserV1Row:
      type: object
      required: [id, email, role]
      properties:
        id:    { type: integer }
        email: { type: string, format: email }
        role:
          type: string
          description: |
            User role name (Admin, EmployerAdmin, EmployerUser, etc).
            Surfaced so a caller can prefer routing reviews to e.g.
            EmployerAdmins.

    ReviewRequestV1Row:
      type: object
      required: [id, applicationId, assignedToUserId, requestedByUserId, requestedAt, status]
      properties:
        id:                 { type: integer }
        applicationId:      { type: integer }
        assignedToUserId:   { type: integer }
        assignedToEmail:    { type: string, nullable: true, format: email }
        requestedByUserId:  { type: integer }
        requestedByEmail:   { type: string, nullable: true, format: email }
        requesterNote:      { type: string, nullable: true, maxLength: 2000 }
        responseNote:       { type: string, nullable: true, maxLength: 4000 }
        requestedAt:        { type: string, format: date-time }
        completedAt:        { type: string, format: date-time, nullable: true }
        status:
          type: string
          enum: [Pending, Completed]
          description: Derived from completedAt; surfaced so callers don't have to compute it.

    ReviewRequestV1CreateRequest:
      type: object
      properties:
        assignedToUserId:
          type: integer
          nullable: true
          description: |
            Internal user id of the reviewer. Either this OR
            `assignedToEmail` must be supplied; if both are present,
            this wins (unambiguous).
        assignedToEmail:
          type: string
          format: email
          nullable: true
          description: |
            Email of the reviewer. Must resolve to a same-tenant user
            (use GET /api/v1/employer/users to discover what's available).
            400 with `assignee_not_found` if the email is unknown.
        note:
          type: string
          maxLength: 2000
          nullable: true
          description: Context the recruiter wants the reviewer to focus on.

    ReviewRequestV1CreateResponse:
      type: object
      required: [id, assignedToUserId]
      properties:
        id:               { type: integer }
        assignedToUserId: { type: integer }

    ReviewRequestV1CompleteRequest:
      type: object
      properties:
        note:
          type: string
          maxLength: 4000
          nullable: true
          description: Brief response. Full feedback should still go via Scorecards.

    ScreeningQuestionV1Row:
      type: object
      required: [id, questionText, questionType, isDefault, isSystemDefault, displayOrder]
      properties:
        id:               { type: integer }
        questionText:     { type: string }
        questionType:
          type: string
          enum: [YesNo, FreeText, SingleChoice, MultiChoice, Numeric]
        options:          { type: string, nullable: true, description: "Pipe-separated for SingleChoice/MultiChoice; null otherwise." }
        correctAnswer:    { type: string, nullable: true, description: "Recruiter-only canonical answer. Never returned on candidate or public surfaces." }
        isDefault:        { type: boolean }
        isSystemDefault:  { type: boolean, description: "True when EmployerId is null — read-only system-wide default." }
        displayOrder:     { type: integer }

    ScreeningQuestionV1CreateRequest:
      type: object
      required: [questionText, questionType]
      properties:
        questionText:   { type: string, minLength: 5, maxLength: 500 }
        questionType:
          type: string
          enum: [YesNo, FreeText, SingleChoice, MultiChoice, Numeric]
        options:        { type: string, nullable: true, maxLength: 2000 }
        correctAnswer:  { type: string, nullable: true, maxLength: 500 }
        displayOrder:   { type: integer, default: 0 }

    ScreeningQuestionV1UpdateRequest:
      type: object
      required: [questionText]
      properties:
        questionText:   { type: string, minLength: 5, maxLength: 500 }
        questionType:
          type: string
          nullable: true
          enum: [YesNo, FreeText, SingleChoice, MultiChoice, Numeric]
        options:        { type: string, nullable: true, maxLength: 2000 }
        correctAnswer:
          type: string
          nullable: true
          maxLength: 500
          description: "PATCH semantics: null = leave alone, empty string = clear back to no preference."
        displayOrder:   { type: integer, nullable: true }

    JobScreeningQuestionV1Row:
      type: object
      required: [questionId, questionText, questionType, isRequired, displayOrder]
      properties:
        questionId:               { type: integer }
        questionText:             { type: string }
        questionType:
          type: string
          enum: [YesNo, FreeText, SingleChoice, MultiChoice, Numeric]
        options:                  { type: string, nullable: true }
        isRequired:               { type: boolean }
        displayOrder:             { type: integer }
        libraryCorrectAnswer:     { type: string, nullable: true, description: "Library row's CorrectAnswer (recruiter-only)." }
        correctAnswerOverride:    { type: string, nullable: true, description: "Per-job override; null = use library default." }

    JobScreeningQuestionV1Attachment:
      type: object
      required: [questionId]
      properties:
        questionId:               { type: integer }
        isRequired:               { type: boolean, default: true }
        displayOrder:             { type: integer, default: 0 }
        correctAnswerOverride:    { type: string, nullable: true, maxLength: 500 }

    JobScreeningQuestionsV1ReplaceRequest:
      type: object
      properties:
        questions:
          type: array
          items: { $ref: "#/components/schemas/JobScreeningQuestionV1Attachment" }

    ScreeningAnswerV1Row:
      type: object
      required: [questionId, questionText, questionType]
      properties:
        questionId:     { type: integer }
        questionText:   { type: string }
        questionType:
          type: string
          enum: [YesNo, FreeText, SingleChoice, MultiChoice, Numeric]
        answerText:     { type: string, nullable: true }
        correctAnswer:  { type: string, nullable: true, description: "Recruiter-only canonical / preferred answer." }

    CandidateScreeningQuestionV1Row:
      type: object
      required: [questionId, questionText, questionType, isRequired, displayOrder]
      properties:
        questionId:     { type: integer }
        questionText:   { type: string }
        questionType:
          type: string
          enum: [YesNo, FreeText, SingleChoice, MultiChoice, Numeric]
        options:        { type: string, nullable: true }
        isRequired:     { type: boolean }
        displayOrder:   { type: integer }
        # Note the deliberate absence of correctAnswer here — candidate
        # side never sees the recruiter's preferred answer.

    CandidateV1AnonymousPreview:
      type: object
      required: [id, maskedName, skills, sectorCodes, nationalities, languages, employmentHistory, educationHistory, willRelocate, isUnlocked]
      properties:
        id:                     { type: integer }
        maskedName:             { type: string, description: "Same masking shape as CandidateV1Match. Real name only after unlock." }
        headline:               { type: string, nullable: true }
        summary:                { type: string, nullable: true, description: "Candidate's free-text bio. May contain weakly identifying phrasing; exposing here is a conscious trade-off for fit assessment." }
        city:                   { type: string, nullable: true }
        state:                  { type: string, nullable: true }
        country:                { type: string, nullable: true, description: "Country name, not ISO code." }
        desiredJobTitle:        { type: string, nullable: true }
        careerStatus:           { type: string, nullable: true }
        availableFrom:          { type: string, format: date-time, nullable: true }
        noticePeriodValue:      { type: integer, nullable: true }
        noticePeriodUnit:       { type: string, nullable: true }
        drivingLicence:         { type: string, nullable: true }
        yearsOfExperience:      { type: integer, nullable: true }
        willRelocate:           { type: boolean }
        expectedSalaryMin:      { type: number, format: float, nullable: true }
        expectedSalaryMax:      { type: number, format: float, nullable: true }
        expectedSalaryCurrency: { type: string, nullable: true }
        expectedSalaryPeriod:   { type: string, nullable: true }
        maxQualificationLevel:  { type: integer, nullable: true }
        skills:                 { type: array, items: { type: string } }
        sectorCodes:            { type: array, items: { type: integer } }
        nationalities:          { type: array, items: { type: string } }
        languages:              { type: array, items: { type: string } }
        employmentHistory:
          type: array
          items: { $ref: "#/components/schemas/CandidateV1EmploymentRow" }
        educationHistory:
          type: array
          items: { $ref: "#/components/schemas/CandidateV1EducationRow" }
        lastActiveAt:           { type: string, format: date-time, nullable: true }
        isUnlocked:             { type: boolean, description: "True when the caller has an active unlock for this candidate (so a subsequent GET /api/v1/candidates/{id} is free)." }
      # Deliberately ABSENT (require unlock to see): firstName,
      # familyName, fullName, email, phone, mobilePhone, photoUrl,
      # linkedInUrl, gitHubUrl, twitterUrl, websiteUrl, CV file/text.

    CandidateV1EmploymentRow:
      type: object
      required: [jobTitle, employerName, isCurrent]
      properties:
        jobTitle:     { type: string }
        employerName: { type: string }
        startDate:    { type: string, format: date-time, nullable: true }
        endDate:      { type: string, format: date-time, nullable: true }
        isCurrent:    { type: boolean }
        summary:      { type: string, nullable: true }

    CandidateV1EducationRow:
      type: object
      required: [institution]
      properties:
        institution:   { type: string }
        qualification: { type: string, nullable: true }
        fieldOfStudy:  { type: string, nullable: true }
        startDate:     { type: string, format: date-time, nullable: true }
        endDate:       { type: string, format: date-time, nullable: true }

    JobV1LabelledCount:
      type: object
      required: [label, count]
      properties:
        label: { type: string }
        count: { type: integer }

    JobV1DailyBucket:
      type: object
      required: [date, views, uniqueVisitors]
      properties:
        date:           { type: string, description: "yyyy-MM-dd." }
        views:          { type: integer }
        uniqueVisitors: { type: integer }

    JobV1DailyApplicationBucket:
      type: object
      required: [date, applications]
      properties:
        date:         { type: string }
        applications: { type: integer }

    JobV1PageViewStats:
      type: object
      required: [jobPostId, from, to, totalViews, uniqueVisitors, last24h, last7d, last30d, internalViews, externalViews, daily, topCountries, topCities, topReferrers, topUtmCampaigns, topUtmSources, topUtmMediums, deviceTypes, topSearchQueries]
      properties:
        jobPostId:        { type: integer }
        from:             { type: string, description: "Window start (ISO 8601)." }
        to:               { type: string, description: "Window end (ISO 8601)." }
        totalViews:       { type: integer }
        uniqueVisitors:   { type: integer }
        last24h:          { type: integer }
        last7d:           { type: integer }
        last30d:          { type: integer }
        internalViews:    { type: integer, description: "Views from same-origin referrers (internal navigation)." }
        externalViews:    { type: integer, description: "Views from off-domain referrers and direct traffic." }
        daily:            { type: array, items: { $ref: "#/components/schemas/JobV1DailyBucket" } }
        topCountries:     { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        topCities:        { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        topReferrers:     { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" }, description: "Top off-domain referring hosts." }
        topUtmCampaigns:  { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        topUtmSources:    { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        topUtmMediums:    { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        deviceTypes:      { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        topSearchQueries: { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" }, description: "Search-engine query terms extracted from Google/Bing/etc. referrers in the window." }

    JobV1ApplicationStats:
      type: object
      required: [jobPostId, from, to, totalApplications, totalViews, postedSeniority, daily, addressCountries, nationalities, ipCountries, experienceBuckets, applicantCities, applicantSeniorities, applicationSources]
      properties:
        jobPostId:              { type: integer }
        from:                   { type: string }
        to:                     { type: string }
        totalApplications:      { type: integer }
        totalViews:             { type: integer }
        conversionRatePercent:  { type: number, format: float, nullable: true, description: "Applications ÷ views × 100. Null when views=0." }
        postedSeniority:        { type: string, description: "JobPost.Seniority at the time of the request." }
        daily:                  { type: array, items: { $ref: "#/components/schemas/JobV1DailyApplicationBucket" } }
        addressCountries:       { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        nationalities:          { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        ipCountries:            { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" }, description: "Country resolved from the applicant's IP at submit time (geo-IP)." }
        experienceBuckets:      { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        applicantCities:        { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" } }
        applicantSeniorities:   { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" }, description: "Seniority distribution derived from each applicant's most recent EmploymentHistory title via SeniorityDetector. 'Unknown' when no detectable signal." }
        applicationSources:     { type: array, items: { $ref: "#/components/schemas/JobV1LabelledCount" }, description: "UTM source tag captured at submit time. 'Direct / on-site' when no UTM present." }

    EmployerMetricsV1Response:
      type: object
      required: [jobs, applications, recentActivity, averageApplicationsPerActiveJob, reach]
      properties:
        jobs:                            { $ref: "#/components/schemas/EmployerMetricsV1Jobs" }
        applications:                    { $ref: "#/components/schemas/EmployerMetricsV1Applications" }
        recentActivity:                  { $ref: "#/components/schemas/EmployerMetricsV1RecentActivity" }
        averageApplicationsPerActiveJob: { type: number, format: float }
        reach:                           { $ref: "#/components/schemas/EmployerMetricsV1Reach" }

    EmployerMetricsV1Jobs:
      type: object
      required: [total, active, draft, expired, inactive]
      properties:
        total:    { type: integer }
        active:   { type: integer }
        draft:    { type: integer }
        expired:  { type: integer }
        inactive: { type: integer }

    EmployerMetricsV1Applications:
      type: object
      required: [total, new, byStage, bySource]
      properties:
        total:    { type: integer }
        new:      { type: integer, description: "Applications currently in the New state (not the total received in a window — that's recentActivity.applicationsReceived30d)." }
        byStage:
          type: object
          additionalProperties: { type: integer }
          description: "Map of ApplicationStatus name → count across all applications the employer has ever received."
        bySource:
          type: object
          additionalProperties: { type: integer }
          description: "Map of ApplicationType name → count (EmailApply, DirectUrl, IndeedApply, ...)."

    EmployerMetricsV1RecentActivity:
      type: object
      required: [jobsPosted30d, applicationsReceived30d]
      properties:
        jobsPosted30d:           { type: integer }
        applicationsReceived30d: { type: integer }

    EmployerMetricsV1Reach:
      type: object
      required: [pageViews30d, searchImpressions30d]
      properties:
        pageViews30d:         { type: integer, description: "Public job-detail page views in the last 30 days across all the employer's jobs." }
        searchImpressions30d: { type: integer, description: "Times one of the employer's jobs appeared in a search results page in the last 30 days." }

    StageFunnelV1Response:
      type: object
      required: [windowDays, totalApplications, stages, drops]
      properties:
        windowDays:        { type: integer, description: "Effective window after server-side clamping to [1, 365]." }
        totalApplications: { type: integer }
        stages:            { type: array, items: { $ref: "#/components/schemas/StageFunnelV1Stage" } }
        drops:             { $ref: "#/components/schemas/StageFunnelV1Drops" }

    StageFunnelV1Stage:
      type: object
      required: [stage, count]
      properties:
        stage:              { type: string, enum: [New, Screened, Interviewed, Offered, Hired] }
        count:              { type: integer, description: "Applications that EVER reached this stage (not just current state)." }
        conversionFromPrev: { type: number, format: float, nullable: true, description: "Per-stage conversion rate from the previous stage (0–1). Null on the first stage and when previous count is 0." }

    StageFunnelV1Drops:
      type: object
      required: [rejected, withdrawn]
      properties:
        rejected:  { type: integer, description: "Applications that ever reached Rejected." }
        withdrawn: { type: integer, description: "Applications that ever reached Withdrawn." }
