openapi: 3.1.0
info:
  title: Orbyt Intelligence API
  description: |
    AI compensation, decoded.

    Orbyt Intelligence is the API + MCP for AI compensation, skills, companies,
    and hiring dynamics. Six engines are designed; TWO serve data today,
    Role Taxonomy and Skill Premiums. Skill Half-Life, Comp by Stage, Company
    Signals and Hiring Velocity hold zero records in production.

     3,445 roles (598 specialized), 81 U.S. cities, 54 company leveling
    frameworks, structured total comp (base, equity, bonus, signing), company
    size salary bands, remote differentials, BLS SOC code citations, and
    lineage on every response. Used by AI research teams, HR tech
    platforms, financial analysts, and newsrooms.

    **Data sources:** BLS Occupational Employment Statistics, H-1B LCA
    (Department of Labor). Cost-of-living adjusted via BEA Regional
    Price Parities.

    **Authentication:** Bearer token required for all tiers (`Authorization: Bearer
    intelligence_...`). Create an account at
    https://www.orbytjobs.ai/intelligence/dashboard.

    **Rate limits and pricing:**
    - Free: 60 req/min, 1,000 requests a month: the role taxonomy, calculate, search, roles and cities
    - Pro: 300 req/min ($99/mo or $990/yr): MCP server, /lineage provenance, restricted API keys
    - Ultra: 1,500 req/min ($199/mo or $1,999/yr): the company leveling catalog and the annual reports

    The Free tier still requires a Bearer token. Free is metered per user, and
    an anonymous request cannot be metered per user, so a key is the price of
    admission rather than money. Create one from the dashboard at no cost.

    Every response includes a `citation` field ready to quote verbatim and an
    `assumptions` array explaining the methodology. Built for LLMs, agents, and
    reproducible research.

    Versioning: every response carries the `Orbyt-Version` header (the RFC-002
    versioning header), currently `2026-05-10`. The envelope shape is locked
    for the life of v1; see the changelog for additive releases.
  version: 1.4.0
  termsOfService: https://www.orbytjobs.ai/terms
  contact:
    name: Orbyt Intelligence Support
    url: https://www.orbytjobs.ai/intelligence
    email: support@orbytjobs.ai
  license:
    name: Orbyt Terms of Service
    url: https://www.orbytjobs.ai/terms

servers:
  - url: https://www.orbytjobs.ai
    description: Production

tags:
  - name: Current
    description: Live salary data for roles and cities
  - name: Calculate
    description: Personalized salary estimate with experience adjustment
  - name: History
    description: Quarterly salary snapshots since Q3 2025
  - name: Projections
    description: Forward salary projections through 2030
  - name: Directory
    description: Roles, cities, and search

paths:
  /api/v1/intelligence/salaries:
    get:
      tags: [Current]
      summary: Current salary data (deprecated)
      deprecated: true
      description: |
        **Deprecated.** Use `/api/v1/intelligence/salaries/calculate` for a
        focused, decision-ready salary estimate with the locked
        Estimate shape (sample_size, confidence bounds, source_breakdown,
        data_point_id, methodology_version).

        This kitchen-sink endpoint bundles role-level data with optional city
        overlay (experience bands, total comp, remote adjustment, company size,
        equity breakdown, skill premiums, education premiums, freelance rates,
        adjacent roles, trends, YoY growth, employer salaries, crowd-sourced
        submissions). Each of those concerns now has a focused endpoint:

        - `/calculate`: personalized estimate (replacement for `/salaries`)
        - `/skills`: skill premiums (Pro+, `skills:read`)
        - `/adjacent`: adjacent role transitions (Pro+)
        - `/employer-cost`: total cost-to-employer
        - `/crowd`: anonymous crowd-sourced aggregate
        - `/companies`: employer-specific premium ranges

        Responses include `Deprecation: true` and `Link: rel="successor-version"`
        headers per RFC 9745. The legacy data fields are preserved for
        backward compatibility but no new fields will be added; new clients
        should consume the focused endpoints directly.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug (e.g., "ai-engineer")
          example: ai-engineer
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug for cost-of-living adjusted data (e.g., "san-francisco")
          example: san-francisco
      responses:
        "200":
          description: Current salary data wrapped in the locked resource envelope.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/SalaryResponse'
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/PermissionDenied'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
        "500":
          $ref: '#/components/responses/InternalError'
        "503":
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/calculate:
    get:
      tags: [Calculate]
      summary: Personalized salary estimate
      description: |
        Returns a personalized salary estimate adjusted for role, city cost
        of living, and experience level. Response uses the locked
        `ResourceEnvelope` shape per RFC-001: `data.estimate` is a full
        locked `Estimate` with sample size, confidence bounds,
        methodology version, source breakdown, and the lookup-able
        `data_point_id`.

        Required scope: `intelligence:read`. Free tier and above. Optional
        expand paths gate on `skills:read` (`role.skills.premium`) and
        `compensation:read` (`comp.by_stage`). See `?expand[]=` parameter.
      parameters:
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug (e.g., `ai-engineer`)
          example: ai-engineer
        - name: city
          in: query
          required: true
          schema: { type: string }
          description: City slug (e.g., `san-francisco`)
          example: san-francisco
        - $ref: '#/components/parameters/CountryParam'
        - name: exp
          in: query
          required: false
          schema:
            type: string
            enum: [entry, mid, senior, staff]
            default: mid
          description: |
            Experience level (multiplier applied to baseline):
            - `entry`: 0-2 years (0.78x)
            - `mid`: 3-5 years (1.0x)
            - `senior`: 6-9 years (1.28x)
            - `staff`: 10+ years (1.55x)
          example: senior
        - name: expand[]
          in: query
          required: false
          description: |
            Expand paths per RFC-002 §5. Stripe-compatible array form.
            Multiple values can be passed as repeated query params or as
            comma-separated `?expand=path1,path2`.

            Allowed paths on this endpoint (some require additional tier or scope):
            - `role.taxonomy`: full taxonomy (Free tier and above)
            - `city.metadata`: full city metadata (Free tier and above)
            - `role.skills.premium`: skill premiums (Pro+ tier, `skills:read` scope)
            - `role.skills.half_life`: trajectory + decay (Pro+ tier, `skills:read` scope)
            - `role.seniority_bands`: comp by seniority (Pro+ tier)
            - `comp.by_stage`: by funding stage (Pro+ tier, `compensation:read` scope)
            - `hiring.velocity`: beta hiring signals (Pro+ tier, `market:read` scope)
            - `*`: wildcard: every accessible path the caller has tier+scope for

            Paths the caller cannot access return `expand_path_forbidden`.
            Unknown paths return `expand_path_unknown`. Sunset paths return
            `expand_path_sunset` with the replacement in `retry_hint.alternative_tool`.
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
          example: [role.taxonomy, city.metadata]
      responses:
        "200":
          description: Personalized estimate envelope.
          headers:
            x-request-id:
              schema:
                type: string
                pattern: "^req_[0-9a-f]{16}$"
              description: Mirrors `request.id`. Searchable in customer log explorer (Phase 3).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/CalculateResponse'
        "400":
          $ref: '#/components/responses/BadRequest'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
        "500":
          $ref: '#/components/responses/InternalError'
        "503":
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/history:
    get:
      tags: [History]
      summary: Quarterly salary history with backward time-travel
      description: |
        Returns quarterly salary snapshots for a role, optionally adjusted
        for a city's cost-of-living. Free tier returns the most-recent 4
        quarters (preview window). Pro+ tiers return the full series with
        extended lookback as new quarters accrue.

        **Universal `?as_of` parameter** (RFC-002 §B4: backward
        time-travel): pass an ISO date `YYYY-MM-DD` to fetch history as it
        existed on that date. Snapshots dated after `as_of` are excluded.
        `attribution.as_of` mirrors the requested date so customers can
        verify the response is point-in-time correct.

        Required scope: `intelligence:read`. Snapshot floor is `2024-07-01`;
        earlier dates return `as_of_too_old`. Future dates return
        `as_of_in_future`.
      parameters:
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug (e.g., `ai-engineer`)
          example: ai-engineer
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug for COL-adjusted history (e.g., `san-francisco`)
          example: san-francisco
        - $ref: '#/components/parameters/CountryParam'
        - name: as_of
          in: query
          required: false
          schema:
            type: string
            format: date
          description: |
            ISO date `YYYY-MM-DD`. Returns history as it existed on that
            date. Defaults to current. Floor: `2024-07-01`. Ceiling:
            today + 1 day (anything later returns `as_of_in_future`).
          example: "2026-01-15"
      responses:
        "200":
          description: |
            Quarterly snapshots with change delta. Each snapshot is a full
            `Estimate` with sample size + confidence + methodology version.
            Free tier truncates to the most-recent 4 quarters and includes
            a `tier_notice` field with upgrade context.
          headers:
            x-request-id:
              schema:
                type: string
                pattern: "^req_[0-9a-f]{16}$"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [role, snapshots, change]
                        properties:
                          role:
                            type: object
                            properties:
                              id: { type: string, example: "role:ai-engineer" }
                              slug: { type: string }
                              title: { type: string }
                          city:
                            type: object
                            nullable: true
                          snapshots:
                            type: array
                            items:
                              allOf:
                                - $ref: '#/components/schemas/Estimate'
                                - type: object
                                  properties:
                                    quarter: { type: string, example: "Q1 2026" }
                          change:
                            type: object
                            nullable: true
                            properties:
                              median_delta: { type: integer }
                              median_pct: { type: number }
                          tier_notice:
                            type: object
                            properties:
                              message: { type: string }
                              snapshots_available: { type: integer }
                              snapshots_returned: { type: integer }
                              upgrade_url: { type: string, format: uri }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
        "503":
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/projections:
    get:
      tags: [Projections]
      summary: Forward salary projections (counterpart to /history)
      description: |
        Year-by-year salary projections through `?through=YYYY` (default
        2030). **Forward time-travel**: symmetric counterpart to `/history`'s
        backward `?as_of`. Both routes validate B4 universal time-travel.

        Methodology is honestly tagged as
        `methodology_version: "2026.2-synthetic-cagr"` because the current
        engine is deterministic compound-annual-growth (AI roles 6-10%,
        standard tech 3-6%), NOT a probabilistic forecast. Phase 2 Day 13
        replaces the synthetic curve with a real model fed by AI Hiring
        Velocity signals: the methodology_version will bump and existing
        callers see the change in `attribution.methodology_version`.

        Confidence intervals widen 4% per year of horizon and
        `confidence_level` decays 5% per year, reflecting genuine reduction
        in forecast precision over time.
      parameters:
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug (e.g., `ai-engineer`)
          example: ai-engineer
        - $ref: '#/components/parameters/CountryParam'
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug for COL-adjusted projections
          example: san-francisco
        - name: through
          in: query
          required: false
          schema:
            type: integer
            minimum: 2026
            maximum: 2030
            default: 2030
          description: |
            Final year (inclusive) of the projection. Floor: current year
            (2026); earlier values return `as_of_too_old`. Ceiling: 2030;
            beyond returns `as_of_in_future` because the synthetic
            forecaster has not been validated past that horizon.
          example: 2028
      responses:
        "200":
          description: |
            Annual projections from `2026` through `?through=YYYY`. Each
            entry is a full `Estimate` with confidence decay applied.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [role, projections, summary, methodology_note]
                        properties:
                          role:
                            type: object
                            properties:
                              id: { type: string }
                              slug: { type: string }
                              title: { type: string }
                          city:
                            type: object
                            nullable: true
                          observed: { $ref: '#/components/schemas/ObservedFigure' }
                          annual_growth_rate:
                            type: number
                            description: Compound annual growth rate (e.g., 0.07 = 7%/yr)
                          annual_growth_pct:
                            type: number
                          projections:
                            type: array
                            items:
                              allOf:
                                - $ref: '#/components/schemas/Estimate'
                                - type: object
                                  properties:
                                    year: { type: integer }
                          summary:
                            type: object
                            properties:
                              start_year: { type: integer }
                              end_year: { type: integer }
                              start_median: { type: integer }
                              end_median: { type: integer }
                              total_growth_pct: { type: number }
                          methodology_note: { type: string }
                          assumptions:
                            type: array
                            items: { type: string }
                          citation: { type: string }
                          page_url: { type: string, format: uri }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/roles:
    get:
      tags: [Directory]
      summary: List the role catalog
      description: |
        Lists every role slug Orbyt Intelligence covers (3,445 as of
        2026 Q2). Public: no Bearer token required. IP-rate-limited
        at 10 req/min; create a free key to lift to 60 req/min.

        **Locked list-envelope shape** (RFC-001 §2.2 + RFC-002 §2-4):
        cursor pagination, filter grammar (equality + multi-value +
        negation + numeric range), sort whitelist, `total_count` always
        included (catalog endpoint).
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
          description: Page size. Hard cap at 100.
          example: 25
        - name: starting_after
          in: query
          required: false
          schema: { type: string }
          description: |
            Opaque cursor from the previous response's `pagination.next_cursor`.
            Pass it back verbatim. Mutually exclusive with `ending_before`.
        - name: ending_before
          in: query
          required: false
          schema: { type: string }
          description: |
            Opaque cursor from the previous response's `pagination.previous_cursor`.
            Mutually exclusive with `starting_after`.
        - name: slug
          in: query
          required: false
          schema: { type: string }
          description: |
            Filter by role slug (equality). Multi-value via comma:
            `?slug=ai-engineer,ml-engineer`. Negation via `?not_slug=...`.
        - name: title
          in: query
          required: false
          schema: { type: string }
          description: Filter by role title (equality, case-sensitive).
        - name: national_median_min
          in: query
          required: false
          schema: { type: integer }
          description: Lower bound on national median (inclusive).
        - name: national_median_max
          in: query
          required: false
          schema: { type: integer }
          description: Upper bound on national median (inclusive).
        - name: sort
          in: query
          required: false
          schema:
            type: string
            enum: [title, slug, national_median]
            default: title
          description: |
            Sort field. Comma-separated for multi-sort
            (`?sort=national_median,title`). Stable secondary sort on `id`.
        - name: order
          in: query
          required: false
          schema:
            type: string
            enum: [asc, desc]
            default: asc
          description: |
            Sort direction. Single value applies to all sorts; comma list
            (`?sort=a,b&order=desc,asc`) applies per-field.
      responses:
        "200":
          description: Paginated catalog of role slugs + titles + national medians.
          headers:
            x-request-id:
              schema:
                type: string
                pattern: "^req_[0-9a-f]{16}$"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          required: [id, slug, title, national_median]
                          properties:
                            id:
                              type: string
                              pattern: "^role:[a-z0-9-]+$"
                              example: "role:ai-engineer"
                            slug: { type: string, example: "ai-engineer" }
                            title: { type: string, example: "AI Engineer" }
                            national_median: { type: integer, example: 240000 }
                            bls_soc_code:
                              type: string
                              nullable: true
                              example: "15-2051"
        "400":
          $ref: '#/components/responses/BadRequest'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security: []

  /api/v1/intelligence/salaries/cities:
    get:
      tags: [Directory]
      summary: List the city catalog
      description: |
        Lists every city slug Orbyt Intelligence covers (~500 U.S. cities)
        with BEA Regional Price Parity cost-of-living multipliers. Public,
        no Bearer token required. IP-rate-limited at 10 req/min.

        Same locked list-envelope shape as `/roles` (RFC-001 §2.2 +
        RFC-002 §2-4). Filter on `slug`, `name`, `state`. Range filter on
        `col_multiplier`. Sort whitelist: `name`, `slug`, `state`,
        `col_multiplier`. Default sort `name asc`.

        **`?country=` behavior on this endpoint.** Catalog endpoints
        accept `?country=GB|CA` without returning 503. The parameter is
        validated for shape but does not currently filter the dataset
        (which is US-only). When Tier 1 international city catalogs land,
        this endpoint will start returning non-US cities and `country`
        becomes a real filter. The 503 dependency_unavailable behavior in
        the global `CountryParam` description applies to compensation
        endpoints (calculate, history, projections, skills, etc.), not
        to this catalog endpoint.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: starting_after
          in: query
          required: false
          schema: { type: string }
          description: Opaque cursor from `pagination.next_cursor`. Mutually exclusive with `ending_before`.
        - name: ending_before
          in: query
          required: false
          schema: { type: string }
        - name: slug
          in: query
          required: false
          schema: { type: string }
          description: Filter by city slug (equality, multi-value `?slug=a,b`, negation `?not_slug=...`).
          example: san-francisco
        - name: name
          in: query
          required: false
          schema: { type: string }
          description: Filter by city name (equality).
        - name: state
          in: query
          required: false
          schema: { type: string }
          description: Filter by 2-letter state code. Multi-value via comma.
          example: CA
        - name: col_multiplier_min
          in: query
          required: false
          schema: { type: number }
          description: Lower bound on COL multiplier (e.g., `1.3` to filter to expensive metros).
        - name: col_multiplier_max
          in: query
          required: false
          schema: { type: number }
        - name: sort
          in: query
          required: false
          schema:
            type: string
            enum: [name, slug, state, col_multiplier]
            default: name
        - name: order
          in: query
          required: false
          schema:
            type: string
            enum: [asc, desc]
            default: asc
      responses:
        "200":
          description: Paginated city catalog with COL multipliers.
          headers:
            x-request-id:
              schema:
                type: string
                pattern: "^req_[0-9a-f]{16}$"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          required: [id, slug, name, state, col_multiplier]
                          properties:
                            id:
                              type: string
                              pattern: "^city:[a-z0-9-]+$"
                              example: "city:san-francisco"
                            slug: { type: string, example: "san-francisco" }
                            name: { type: string, example: "San Francisco" }
                            state: { type: string, example: "CA" }
                            col_multiplier:
                              type: number
                              format: float
                              minimum: 0
                              example: 1.42
        "400":
          $ref: '#/components/responses/BadRequest'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security: []

  /api/v1/intelligence/salaries/search:
    get:
      tags: [Directory]
      summary: Text-search roles by keyword
      description: |
        Full-text search across the 3,445-role catalog. Resolves natural-
        language phrases (`"machine learning engineer"`, `"prompt engineer"`)
        to canonical slugs that the other endpoints accept.

        **Locked text-search pattern.** `?q=` is the ONLY substring filter
        on this endpoint; everything else in RFC-002 §3 is strict equality.
        Each result carries a normalized `score` field (0..1) that downstream
        SDK clients use for relevance UI without re-running search. Default
        sort is `score DESC` with a stable tiebreak on `id`.

        Public: no Bearer token required. IP-rate-limited at 10 req/min.
        Limit cap is 50 (lower than catalog endpoints because results are
        scored top-N, customers don't need full pages).
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 200 }
          description: Search query. Multiple words AND together inside the slug+title corpus.
          example: machine learning
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
          description: Page size. Hard cap at 50 (search-specific lower than catalog endpoints).
        - name: starting_after
          in: query
          required: false
          schema: { type: string }
          description: Opaque cursor from `pagination.next_cursor`.
        - name: sort
          in: query
          required: false
          schema:
            type: string
            enum: [score, title, national_median]
            default: score
          description: |
            Sort field. Default `score` (relevance DESC). `title` /
            `national_median` allow alphabetical / salary-ranked overrides.
        - name: order
          in: query
          required: false
          schema:
            type: string
            enum: [asc, desc]
            default: desc
          description: Sort direction. Defaults to `desc` so highest-relevance/highest-salary appears first.
      responses:
        "200":
          description: |
            Top-N matching roles by relevance. Each hit carries a `score`
            in `[0, 1]` derived from slug-exactness + title-prefix +
            substring containment.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          required: [id, slug, title, national_median, score]
                          properties:
                            id:
                              type: string
                              pattern: "^role:[a-z0-9-]+$"
                            slug: { type: string }
                            title: { type: string }
                            national_median: { type: integer }
                            score:
                              type: number
                              minimum: 0
                              maximum: 1
                              description: Normalized relevance score (1.0 = exact slug match).
        "400":
          $ref: '#/components/responses/BadRequest'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security: []

  /api/v1/intelligence/salaries/compare:
    get:
      tags: [Compare]
      summary: Side-by-side role comparison (multi-resource pattern)
      description: |
        Returns two role estimates plus a `delta` block describing the
        difference. **Locked multi-resource shape** per RFC-001:
        `data: { primary, secondary, delta, city?, citation }`. Future
        endpoints comparing two entities (e.g., `/lineage/:id`) reuse
        this exact shape.

        Optional `?city=` adjusts both estimates with the same COL
        multiplier so the delta reflects true compensation difference,
        not regional variance. `data.delta.higher_paid` is `"roleA" |
        "roleB" | "tie"`.

        Required scope: `intelligence:read`.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: roleA
          in: query
          required: true
          schema: { type: string }
          description: First role slug
          example: ai-engineer
        - name: roleB
          in: query
          required: true
          schema: { type: string }
          description: Second role slug. Must differ from `roleA`.
          example: ml-engineer
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: Optional city slug for COL-adjusted comparison.
          example: san-francisco
      responses:
        "200":
          description: Two role estimates + delta + city + citation.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [primary, secondary, delta, citation, page_url]
                        properties:
                          primary:
                            type: object
                            properties:
                              role:
                                type: object
                                properties:
                                  id: { type: string, example: "role:ai-engineer" }
                                  slug: { type: string }
                                  title: { type: string }
                              estimate:
                                $ref: '#/components/schemas/Estimate'
                              observed:
                                $ref: '#/components/schemas/ObservedFigure'
                              total_compensation:
                                type: object
                                properties:
                                  base: { type: integer }
                                  equity: { type: integer }
                                  bonus: { type: integer }
                                  signing: { type: integer }
                                  total: { type: integer }
                              negotiation_leverage:
                                type: object
                                nullable: true
                          secondary:
                            type: object
                            description: Same shape as `primary`.
                          city:
                            type: object
                            nullable: true
                            description: Optional, mirrors the `?city=` parameter when supplied.
                          delta:
                            type: object
                            required: [higher_paid, median_delta, median_pct, total_comp_delta, total_comp_pct]
                            properties:
                              higher_paid:
                                type: string
                                enum: [roleA, roleB, tie]
                              median_delta:
                                type: integer
                                description: roleA.median - roleB.median (signed).
                              median_pct: { type: number }
                              total_comp_delta: { type: integer }
                              total_comp_pct: { type: number }
                          citation: { type: string }
                          page_url: { type: string, format: uri }
                          # `observed` is carried on `primary` and `secondary`
                          # individually, not at this level. The two roles usually
                          # map to DIFFERENT occupation codes, and one shared block
                          # would attribute one role's published wage to both.
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/adjacent:
    get:
      tags: [Adjacent]
      summary: Career-adjacent roles (3-bucket transition pattern)
      description: |
        Returns the nearest roles by salary for career transition planning.
        Locked 3-bucket structure: `data.buckets.higher_paying`,
        `data.buckets.lateral`, `data.buckets.lower_paying`. Each entry
        carries a `direction` enum and `difference_percent` so SDK clients
        can render gap-percentage UIs.

        MCP `find_adjacent_opportunities` tool (Phase 4 Day 36) wraps this
        endpoint verbatim: the same JSON ships into the agent's
        decision-ready response.

        Required scope: `intelligence:read`.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: role
          in: query
          required: true
          schema: { type: string }
          example: ai-engineer
        - name: count
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 20, default: 5 }
          description: Number of adjacent roles to return. Cap at 20.
      responses:
        "200":
          description: Adjacent roles + 3-bucket structure + summary.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [role, adjacent, buckets, summary]
                        properties:
                          role:
                            type: object
                            properties:
                              id: { type: string }
                              slug: { type: string }
                              title: { type: string }
                              median: { type: integer }
                          adjacent:
                            type: array
                            items:
                              type: object
                              properties:
                                id: { type: string }
                                role:
                                  type: object
                                  properties:
                                    id: { type: string }
                                    slug: { type: string }
                                    title: { type: string }
                                median: { type: integer }
                                difference: { type: integer }
                                difference_percent: { type: number }
                                direction:
                                  type: string
                                  enum: [higher, lateral, lower]
                                methodology_version: { type: string }
                          buckets:
                            type: object
                            properties:
                              higher_paying: { type: array, items: { type: object } }
                              lateral: { type: array, items: { type: object } }
                              lower_paying: { type: array, items: { type: object } }
                          summary:
                            type: object
                            properties:
                              higher_paying_count: { type: integer }
                              lateral_count: { type: integer }
                              lower_paying_count: { type: integer }
                              nearest_higher: { type: object, nullable: true }
                              nearest_lateral: { type: object, nullable: true }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/skills:
    get:
      tags: [Skills]
      summary: AI skill premiums (Pro+ tier, scope-gated)
      description: |
        Returns AI skills ranked by salary premium. Supports two modes:

        - **Catalog mode** (no `?role=`): returns the full skill premium
          ranking (~50 skills) sorted by premium amount DESC.
        - **Per-role mode** (`?role=<slug>`): returns adjusted salary
          per skill for that role + optional city.

        **Scope-gated** per RFC-002 §6: requires BOTH `intelligence:read`
        AND `skills:read`. New keys default to `intelligence:read` only;
        customers must explicitly grant `skills:read` in the dashboard.
        This is the canonical pattern for engine-specific scopes.

        **Pro+ tier required.** Free tier returns
        `tier_insufficient` with upgrade follow-ups. The `/calculate`
        endpoint with `?expand[]=role.skills.premium` provides equivalent
        per-role data; that path also requires `skills:read`.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - name: role
          in: query
          required: false
          schema: { type: string }
          description: Role slug. Omit for catalog mode.
          example: ai-engineer
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug for COL-adjusted base salary in per-role mode.
          example: san-francisco
      responses:
        "200":
          description: |
            Catalog mode returns `data.mode: "catalog"` + ranked array.
            Per-role mode returns `data.mode: "role"` + `data.role` +
            optional `data.city` + adjusted-salary array per skill.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        oneOf:
                          - type: object
                            required: [mode, count, skills]
                            properties:
                              mode: { type: string, enum: [catalog] }
                              count: { type: integer }
                              skills:
                                type: array
                                items:
                                  type: object
                                  required: [id, skill, slug, premium, premium_pct, category]
                                  properties:
                                    id: { type: string, pattern: "^skill:" }
                                    skill: { type: string }
                                    slug: { type: string }
                                    premium: { type: number }
                                    premium_pct: { type: integer }
                                    category: { type: string }
                                    description: { type: string }
                              methodology_note: { type: string }
                          - type: object
                            required: [mode, role, base_salary, count, skills]
                            properties:
                              mode: { type: string, enum: [role] }
                              role:
                                type: object
                                properties:
                                  id: { type: string }
                                  slug: { type: string }
                                  title: { type: string }
                              city:
                                type: object
                                nullable: true
                              base_salary: { type: integer }
                              observed:
                                $ref: '#/components/schemas/ObservedFigure'
                              count: { type: integer }
                              skills:
                                type: array
                                items:
                                  type: object
                                  required: [id, skill, slug, premium, base_salary, adjusted_salary, increase, methodology_version]
                                  properties:
                                    id: { type: string, pattern: "^skill:" }
                                    skill: { type: string }
                                    slug: { type: string }
                                    premium: { type: number }
                                    premium_pct: { type: integer }
                                    category: { type: string }
                                    base_salary: { type: integer }
                                    adjusted_salary: { type: integer }
                                    increase: { type: integer }
                                    methodology_version: { type: string }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          description: |
            Returns `tier_insufficient` (Free tier) or `scope_insufficient`
            (key lacks `skills:read`). Both follow the locked error envelope
            with `follow_up_suggestions` listing upgrade and alternative-tool paths.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/employer-cost:
    get:
      tags: [EmployerCost]
      summary: Total cost-to-employer (inverted-perspective Estimate)
      description: |
        Returns total cost-to-employer including payroll tax, health
        insurance, 401(k) match, benefits, recruiting, and overhead.
        Uses the same `Estimate` shape as `/calculate` but values
        represent **employer cost** (typically 1.25x..1.4x base).

        `data.cost_multiplier` shows the all-in factor;
        `data.breakdown` returns per-component disclosure.
        `source_breakdown` reflects the documented 50/30/20 weights:
        base salary data, benefits benchmarks, payroll tax tables.

        Required scope: `intelligence:read`.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - { name: role, in: query, required: true, schema: { type: string }, example: ai-engineer }
        - { name: city, in: query, required: false, schema: { type: string }, example: san-francisco }
        - name: size
          in: query
          required: false
          schema: { type: string, enum: [startup, midmarket, enterprise], default: midmarket }
      responses:
        "200":
          description: Employer cost envelope with inverted Estimate + breakdown.
          headers:
            x-request-id: { schema: { type: string, pattern: "^req_[0-9a-f]{16}$" } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required: [role, company_size, base_salary, estimate, cost_multiplier, breakdown, summary]
                        properties:
                          role: { type: object }
                          city: { type: object, nullable: true }
                          company_size: { type: string, enum: [startup, midmarket, enterprise] }
                          base_salary: { type: integer }
                          estimate: { $ref: '#/components/schemas/Estimate' }
                          cost_multiplier: { type: number, minimum: 1, maximum: 2 }
                          observed: { $ref: '#/components/schemas/ObservedFigure' }
                          breakdown:
                            type: object
                            additionalProperties: { type: integer }
                          summary: { type: string }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/widget:
    get:
      tags: [Directory]
      summary: "Public widget data (legacy flat shape: locked for embed compat)"
      description: |
        Minimal-shape salary data for embeddable widgets. Public: no Bearer
        token required. IP-rate-limited at 10 req/min.

        This endpoint is the ONE Intelligence route that returns the legacy
        flat shape (not the RFC-001 envelope). Reason: `public/embed/salary.js`
        is cached on every third-party site that has pasted the embed script,
        and the JS reads top-level `role`, `salary`, `experienceBands`,
        `whitelabel` directly. The field names are part of the customer-facing
        contract that shipped at launch. Per CLAUDE.md: "Once a field ships
        in a v1 API response, it never disappears."

        Optional `Authorization: Bearer wlk_*` whitelabel key suppresses the
        `poweredBy` block. Any whitelabel validation failure silently
        falls back to branded mode so customer sites never break. Cache-Control
        is `private, no-store` for whitelabel responses and
        `public, s-maxage=...` for branded responses (CDN-safe).
      parameters:
        - { name: role, in: query, required: true, schema: { type: string } }
        - { name: city, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: "Legacy flat shape: role/city/salary/experienceBands/whitelabel/poweredBy/attribution at top level."
          headers:
            x-request-id: { schema: { type: string, pattern: "^req_[0-9a-f]{16}$" } }
          content:
            application/json:
              schema:
                type: object
                required: [role, salary, experienceBands, whitelabel, attribution]
                properties:
                  role:
                    type: object
                    required: [slug, title]
                    properties:
                      slug: { type: string }
                      title: { type: string }
                  city:
                    type: object
                    nullable: true
                    properties:
                      slug: { type: string }
                      name: { type: string }
                      state: { type: string }
                  salary:
                    type: object
                    required: [low, median, high, currency]
                    properties:
                      low: { type: integer }
                      median: { type: integer }
                      high: { type: integer }
                      currency: { type: string, example: USD }
                  experienceBands:
                    type: array
                    items:
                      type: object
                      properties:
                        level: { type: string }
                        low: { type: integer }
                        high: { type: integer }
                  observed:
                    $ref: '#/components/schemas/ObservedFigure'
                  whitelabel: { type: boolean }
                  poweredBy:
                    type: object
                    nullable: true
                    description: Present in branded mode (whitelabel=false), absent in whitelabel mode.
                    properties:
                      name: { type: string }
                      url: { type: string, format: uri }
                      signup: { type: string, format: uri }
                  attribution:
                    type: object
                    description: Flat attribution block (legacy shape, not the locked Attribution model used by other v1 routes).
                    properties:
                      source: { type: string }
                      url: { type: string, format: uri }
                      methodology: { type: string, format: uri }
                      updated: { type: string, format: date }
        "400":
          $ref: '#/components/responses/BadRequest'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security: []

  /api/v1/intelligence/salaries/demo:
    get:
      tags: [Directory]
      summary: Public quickstart envelope (no auth)
      description: |
        Returns a hardcoded sample response with the locked Phase 1 envelope
        shape so SDK customers see the canonical structure as their first
        response. Includes `data.sdk_quickstart` with TypeScript + cURL
        snippets. `methodology_version: "2026.2-demo"` is honestly tagged.

        Public: no Bearer token required. IP-rate-limited at 10 req/min.
      responses:
        "200":
          description: Quickstart envelope with full Estimate + experience matrix + SDK snippets.
          headers:
            x-request-id: { schema: { type: string, pattern: "^req_[0-9a-f]{16}$" } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          demo: { type: boolean, enum: [true] }
                          note: { type: string }
                          role: { type: object }
                          city: { type: object }
                          estimate: { $ref: '#/components/schemas/Estimate' }
                          by_experience: { type: object }
                          docs_url: { type: string, format: uri }
                          signup_url: { type: string, format: uri }
                          sdk_quickstart:
                            type: object
                            properties:
                              typescript: { type: string }
                              curl: { type: string }
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security: []

  /api/v1/intelligence/salaries/companies:
    get:
      tags: [Directory]
      summary: Company leveling catalog (list pattern)
      description: |
        Lists ~50 tracked companies (FAANG, AI labs, unicorns, public tech,
        enterprise) with leveling, equity vesting, and interview difficulty.
        Same locked list-envelope shape as `/roles` and `/cities`.

        **`?country=` behavior on this endpoint.** Like the other catalog
        endpoints, this accepts `?country=GB|CA` without returning 503.
        The parameter is validated for shape but does not currently
        filter the dataset (which is US-tracked-companies-only). When
        Tier 1 international company signals land, this endpoint will
        start returning non-US firms and `country` becomes a real filter.

        Required scope: `intelligence:read`.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: starting_after, in: query, schema: { type: string } }
        - { name: slug, in: query, schema: { type: string }, description: "Equality filter on company slug." }
        - { name: name, in: query, schema: { type: string } }
        - { name: category, in: query, schema: { type: string }, description: "e.g., faang, ai-lab, unicorn, public-tech, enterprise" }
        - { name: employee_count_min, in: query, schema: { type: integer } }
        - { name: employee_count_max, in: query, schema: { type: integer } }
        - { name: sort, in: query, schema: { type: string, enum: [name, slug, category, employee_count], default: name } }
        - { name: order, in: query, schema: { type: string, enum: [asc, desc], default: asc } }
      responses:
        "200":
          description: Paginated company catalog.
          headers:
            x-request-id: { schema: { type: string, pattern: "^req_[0-9a-f]{16}$" } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string, pattern: "^company:" }
                            slug: { type: string }
                            name: { type: string }
                            category: { type: string }
                            employee_count: { type: integer, nullable: true }
                            headquarters: { type: string }
                            level_count: { type: integer }
                            top_level: { type: object, nullable: true }
                            page_url: { type: string, format: uri }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/compare-offers:
    post:
      tags: [Compare]
      summary: Compare 2-5 job offers (POST + multi-N pattern)
      description: |
        Returns ranked offers with COL-normalized after-tax totals,
        per-offer Estimate-shape with tax breakdown, market comparison,
        and a `delta` block (winner_id, gap_to_runner_up, gap_to_lowest).
        Extends `/compare`'s 2-entity multi-resource shape to N entities.

        **POST + Idempotency-Key.** Pass `Idempotency-Key: <uuid>` to
        deduplicate retries. Header echoed back on success.

        Required scope: `intelligence:read` (analysis-only: does not
        persist offer data).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, minLength: 8, maxLength: 200, pattern: "^[A-Za-z0-9_-]+$" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [offers]
              properties:
                offers:
                  type: array
                  minItems: 2
                  maxItems: 5
                  items:
                    type: object
                    required: [baseSalary]
                    properties:
                      company: { type: string }
                      role: { type: string }
                      baseSalary: { type: number, minimum: 20000, maximum: 5000000 }
                      equityAnnual: { type: number }
                      bonus: { type: number }
                      signingBonus: { type: number }
                      city: { type: string }
                      workMode: { type: string, enum: [onsite, remote, hybrid] }
                filing: { type: string, enum: [single, married], default: single }
                country:
                  $ref: '#/components/schemas/CountryCode'
                  description: |
                    ISO 3166-1 alpha-2 country code per RFC-006 §9.1. Default
                    'US' when omitted. Tier 1 international codes (GB, CA)
                    return 503 dependency_unavailable while data is pending
                    operator-side rollout.
      responses:
        "200":
          description: Ranked offers + multi-N delta + insights.
          headers:
            x-request-id: { schema: { type: string, pattern: "^req_[0-9a-f]{16}$" } }
            Idempotency-Key:
              schema: { type: string }
              description: Echoed back when caller passed the header.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          filing: { type: string, enum: [single, married] }
                          offers:
                            type: array
                            items:
                              type: object
                              properties:
                                id: { type: string, pattern: "^offer:" }
                                rank: { type: integer }
                                company: { type: string }
                                role: { type: string }
                                compensation: { type: object }
                                after_tax: { type: object }
                                adjustments: { type: object }
                                market_comparison: { type: object, nullable: true }
                          delta:
                            type: object
                            properties:
                              winner_id: { type: string }
                              winner_company: { type: string }
                              winner_total_col_normalized: { type: integer }
                              gap_to_runner_up: { type: integer }
                              gap_to_runner_up_pct: { type: number }
                              gap_to_lowest: { type: integer }
                          insights: { type: array, items: { type: string } }
                          shareable_url: { type: string, format: uri }
                          methodology_note: { type: string }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/submit:
    post:
      tags: [Crowd]
      summary: Submit anonymous salary data (POST + idempotency-key)
      description: |
        Contribute compensation data anonymously. 3 submissions per day
        per user. Individual data never exposed; aggregates published
        via `/crowd` once a role/company combo has 5+ submissions.

        **POST + Idempotency-Key.** Locked write-endpoint pattern per
        RFC-001. Cross-origin requests blocked unless verifyOrigin passes.

        Required scope: `intelligence:write`. Granted per key, not by tier.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, minLength: 8, maxLength: 200, pattern: "^[A-Za-z0-9_-]+$" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company, role, baseSalary]
              properties:
                company: { type: string, maxLength: 200 }
                role: { type: string, maxLength: 200 }
                baseSalary: { type: number, minimum: 20000, maximum: 5000000 }
                equityYearly: { type: number, minimum: 0, maximum: 5000000 }
                bonusAnnual: { type: number, minimum: 0, maximum: 5000000 }
                signingBonus: { type: number, minimum: 0, maximum: 1000000 }
                city: { type: string, maxLength: 100 }
                yearsExperience: { type: integer, minimum: 0, maximum: 50 }
                level: { type: string, enum: [entry, mid, senior, staff, principal] }
                workMode: { type: string, enum: [onsite, remote, hybrid] }
                education: { type: string, enum: [high-school, bachelors, masters, phd, bootcamp, none] }
                skills: { type: array, items: { type: string }, maxItems: 20 }
                visaType: { type: string, enum: [citizen, h1b, l1, greencard, other] }
                country:
                  $ref: '#/components/schemas/CountryCode'
                  description: |
                    ISO 3166-1 alpha-2 country code per RFC-006 §9.1. Default
                    'US' when omitted. Tier 1 international codes (GB, CA)
                    return 503 dependency_unavailable while data is pending
                    operator-side rollout. Stripe API team finding S-P0-2.
      responses:
        "201":
          description: Submission accepted with stable submission_id.
          headers:
            x-request-id: { schema: { type: string, pattern: "^req_[0-9a-f]{16}$" } }
            Idempotency-Key: { schema: { type: string }, description: "Echoed back when provided." }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, pattern: "^submission:" }
                          submission_id: { oneOf: [{ type: string }, { type: integer }] }
                          accepted_at: { type: string, format: date-time }
                          visibility: { type: string }
                          methodology_version: { type: string }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
        "503":
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/crowd:
    get:
      tags: [Crowd]
      summary: Aggregated community-reported data (privacy disclosure pattern)
      description: |
        Returns p25/median/p75 stats from anonymous submissions.

        **Privacy disclosure pattern:** below threshold (N<5), returns 200
        with `data.status: "insufficient"`, explicit `sample_size`,
        `threshold_required`, and `aggregates: null`. At/above threshold,
        returns full aggregate. Sub-group cells (by_experience, by_work_mode)
        require N≥3.

        **Data flywheel:** caller must have submitted ≥1 salary first.
        Otherwise returns `tier_insufficient` pointing to `/submit`.

        Required scope: `intelligence:read`.
      parameters:
        - $ref: '#/components/parameters/CountryParam'
        - { name: role, in: query, required: false, schema: { type: string } }
        - { name: company, in: query, required: false, schema: { type: string } }
        - { name: city, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Aggregated stats with status enum (`ok` or `insufficient`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          status: { type: string, enum: [ok, insufficient] }
                          query: { type: object }
                          sample_size: { type: integer }
                          threshold_required: { type: integer }
                          submissions_needed: { type: integer }
                          aggregates: { type: object, nullable: true }
                          by_experience: { type: array, items: { type: object } }
                          by_work_mode: { type: array, items: { type: object } }
                          submission_window: { type: object }
                          privacy_notice: { type: string }
                          methodology_version: { type: string }
                          message: { type: string }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          description: |
            Returns `tier_insufficient` (data flywheel: caller has no submissions).
            `retry_strategy: "use_alternative_tool"`, `retry_hint.alternative_tool: "/api/v1/intelligence/salaries/submit"`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/negotiate:
    post:
      tags: [Negotiate]
      summary: AI negotiation script generator (Pro+ POST + AI generation)
      description: |
        Returns counter-offer numbers + 5 talking points + ready-to-send
        email + phone script + warnings. Counter-offer numbers are
        **anchored to market data** (deterministic). Talking points and
        email/phone scripts are **AI-generated** with Orbyt's voice rules
        (no em-dashes, no fabricated data). `data.methodology_note`
        distinguishes deterministic from AI-generated fields.

        Pro+ tier required. AI provider failure surfaces as
        `dependency_unavailable` (never raw provider text leaks).
        Invalid model JSON also surfaces structured.

        Required scope: `intelligence:read + intelligence:write`.
        @rateLimit Pro: 10/hour, Ultra: 50/hour
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, minLength: 8, maxLength: 200, pattern: "^[A-Za-z0-9_-]+$" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentOffer]
              properties:
                currentOffer:
                  type: object
                  required: [baseSalary]
                  properties:
                    company: { type: string }
                    role: { type: string }
                    baseSalary: { type: number, minimum: 20000, maximum: 5000000 }
                    equity: { type: number }
                    bonus: { type: number }
                    signing: { type: number }
                    city: { type: string }
                context: { type: string, maxLength: 1000, description: "Additional context (competing offers, experience, etc.)" }
                country:
                  $ref: '#/components/schemas/CountryCode'
                  description: |
                    ISO 3166-1 alpha-2 country code per RFC-006 §9.1. Default
                    'US' when omitted. Tier 1 international codes (GB, CA)
                    return 503 dependency_unavailable while data is pending
                    operator-side rollout. Stripe API team finding S-P0-2.
      responses:
        "200":
          description: Negotiation strategy with counter-offer + scripts + warnings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          offer: { type: object }
                          analysis: { type: object }
                          observed: { $ref: '#/components/schemas/ObservedFigure' }
                          counter_offer: { type: object, nullable: true }
                          talking_points: { type: array, items: { type: string } }
                          email_template: { type: string, nullable: true }
                          phone_script: { type: string, nullable: true }
                          warnings: { type: array, items: { type: string } }
                          methodology_note: { type: string }
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          description: |
            Free tier returns `tier_insufficient` with upgrade follow-ups.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        "503":
          description: |
            AI provider failure or invalid JSON returns `dependency_unavailable`
            with `retry_strategy: "retry_after_seconds"`. Never raw provider text.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
      security:
        - bearerAuth: []

  /api/v1/intelligence/salaries/hubs/{hub}:
    get:
      tags: [Directory]
      summary: AI salary hub dataset download
      description: |
        Public JSON dataset for an AI salary hub (e.g., `software-engineering`,
        `artificial-intelligence`). Returns every role classified into the hub
        with national salary baselines, sorted by base_median DESC.

        **Locked list-envelope shape** with hub metadata in response headers
        (`X-Hub-Slug`, `X-Hub-Title`, `X-Hub-Category`, `X-Quarter`,
        `X-License`). CC BY 4.0 license; attribution to "Orbyt Intelligence"
        required for redistribution.

        Used by Perplexity, Common Crawl, and academic data consumers
        following the Dataset JSON-LD `distribution` link from the hub
        marketing page. IP rate-limited at 30 req/min.
      parameters:
        - name: hub
          in: path
          required: true
          schema: { type: string }
          description: Hub slug (e.g., `software-engineering`, `artificial-intelligence`)
          example: artificial-intelligence
        - $ref: '#/components/parameters/CountryParam'
      responses:
        "200":
          description: Hub roles with national salary baselines
          headers:
            X-Hub-Slug:
              schema: { type: string }
              description: Hub slug echoed for client convenience
            X-Hub-Title:
              schema: { type: string }
            X-Hub-Category:
              schema: { type: string }
            X-Quarter:
              schema: { type: string }
              description: Data quarter (e.g., `Q2 2026`)
            X-License:
              schema: { type: string, enum: ["CC BY 4.0"] }
            X-License-Url:
              schema: { type: string, format: uri }
            X-Stats-Role-Count:
              schema: { type: integer }
            X-Stats-Median-Salary:
              schema: { type: integer }
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'

  /api/v1/intelligence/lineage/{data_point_id}:
    get:
      tags: [Lineage]
      summary: field-level provenance lookup
      description: |
        Returns the full provenance trail for a `data_point_id` produced by
        any Intelligence response. Every Estimate carries a stable
        `data_point_id` (e.g.,
        `ai-engineer:san-francisco:median_base:2026-Q2:senior`); this
        endpoint resolves that ID to the per-source breakdown, methodology
        version, ingestion timestamps, computed value at lineage time, and
        disagreement flag.

        **Pro+ tier required** with the `intelligence:lineage` scope (a
        separate opt-in scope from `intelligence:read`). Lineage queries
        are a premium transparency feature: every customer sees
        `data_point_id` in their Estimates regardless of tier, but
        resolving it requires Pro and the lineage scope.

        The `data_lineage` table is populated by the Phase 2A ingestion
        pipeline. Until that ships, every lineage query returns 404
        `data_point_not_found` with a `phase_2_pending` follow-up
        suggestion. The shape is locked NOW so customers can integrate
        against the documented response and the live data populates
        automatically.
      parameters:
        - name: data_point_id
          in: path
          required: true
          schema:
            type: string
            pattern: "^[A-Za-z0-9][A-Za-z0-9:_.-]{2,200}$"
          description: |
            Stable resource ID returned in `data.estimate.data_point_id` of
            any Estimate response. Format:
            `<role-slug>:<city-slug-or-national>:<measurement>:<as_of>[:<segment>]`.
          example: ai-engineer:san-francisco:median_base:2026-Q2:senior
      responses:
        "200":
          description: Full provenance trail wrapped in the locked resource envelope.
          headers:
            x-request-id:
              schema: { type: string, pattern: "^req_[0-9a-f]{16}$" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ResourceEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        required:
                          - id
                          - data_point_id
                          - table_name
                          - primary_key
                          - methodology_version
                          - methodology_url
                          - source_breakdown
                          - disagreement_flag
                          - computed_value
                          - sources
                          - corrections_url
                          - lineage_recorded_at
                        properties:
                          id:
                            type: string
                            description: "Resource ID prefixed with `lineage:` (e.g., `lineage:ai-engineer:san-francisco:median_base:2026-Q2`)"
                          data_point_id:
                            type: string
                          table_name:
                            type: string
                            description: "Internal table the row lives in (e.g., `ai_role_seniority_bands`)"
                          primary_key:
                            type: object
                            additionalProperties: true
                            description: "Composite key fields that identify the source row"
                          methodology_version:
                            type: string
                            example: "2026.2"
                          methodology_url:
                            type: string
                            format: uri
                          source_breakdown:
                            $ref: '#/components/schemas/SourceBreakdown'
                          disagreement_flag:
                            type: boolean
                            description: "True when sources disagree by >25% per RFC-001 §6"
                          computed_value:
                            type: object
                            additionalProperties: true
                            description: "The computed value at lineage time, mirroring the Estimate fields the data_point_id originated from"
                          sources:
                            type: array
                            items:
                              type: object
                              required: [name, url, ingested_at, version, weight]
                              properties:
                                name:
                                  type: string
                                  description: "Source name (e.g., `BLS OES`, `H-1B LCA`, `Pay Transparency Aggregate`, `CommonCrawl WARC archive`)"
                                url:
                                  type: string
                                  format: uri
                                  description: "Permanent URL to the underlying data"
                                ingested_at:
                                  type: string
                                  format: date-time
                                version:
                                  type: string
                                  description: "Source version tag (e.g., `2025 May release`)"
                                weight:
                                  type: number
                                  minimum: 0
                                  maximum: 1
                                value_contribution:
                                  type: object
                                  additionalProperties:
                                    type: number
                                  description: "What value each source contributed before weighting"
                          corrections_url:
                            type: string
                            format: uri
                          lineage_recorded_at:
                            type: string
                            format: date-time
        "400":
          $ref: '#/components/responses/BadRequest'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/PermissionDenied'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
        "500":
          $ref: '#/components/responses/InternalError'
      security:
        - bearerAuth: []

  # ── MCP HTTP transport (JSON-RPC 2.0 per the MCP spec) ──────────
  #
  # Phase 4 Day 50 endpoint. Customers POST a JSON-RPC 2.0 envelope and
  # the server dispatches to one of 6 locked tools (list_capabilities,
  # analyze_compensation, analyze_skills, analyze_market,
  # discover_roles_and_cities, find_adjacent_opportunities).
  #
  # This route is INTENTIONALLY exempt from the locked RFC-001 envelope
  # because it speaks JSON-RPC 2.0, not REST. Auth/rate-limit failures
  # still use the legacy REST shape (HTTP 401/429): those are
  # route-layer concerns, not MCP protocol concerns.

  /api/v1/intelligence/mcp:
    post:
      tags: [MCP]
      summary: "MCP HTTP transport (JSON-RPC 2.0)"
      description: |
        POST a JSON-RPC 2.0 request to dispatch one of the 6 locked MCP
        tools. The response body is a JSON-RPC 2.0 response (success or
        structured error). The full Decision-Ready Response shape per
        RFC-004 §3 lives inside `result.content[0].text` (stringified
        JSON): agents quote `answer` verbatim, chain via
        `follow_up_suggestions`, trace via `citation.request_id`.

        **Spec compliance:**
        - JSON-RPC 2.0 envelope (`{jsonrpc, id, method, params}`)
        - MCP spec methods: `initialize`, `notifications/initialized`,
          `notifications/cancelled`, `ping`, `tools/list`, `tools/call`
        - Cancellation: send `notifications/cancelled` with the same
          `requestId`; the server suppresses the response (the tool
          body keeps running but its result is dropped)
        - Protocol version negotiation (2026-06-09): `initialize`
          returns an OFFICIAL MCP protocol revision. Supported,
          newest first: `2025-11-25`, `2025-06-18`, `2025-03-26`,
          `2024-11-05`. A supported requested `protocolVersion` is
          echoed verbatim; anything else (including a missing value)
          gets `2025-11-25`. Official-SDK clients (Claude Desktop,
          Claude Code, mcp-remote) complete the handshake.

        **Tier → default scopes mapping:**
        - `free` → `intelligence:read`
        - `pro` → adds `skills:read`, `market:read`, `compensation:read`
        - `ultra` → adds `company_data:read`

        `intelligence:write` is default-granted by no tier; it is an explicit
        per-key grant.

        **Optional headers:**
        - `X-Orbyt-Tier: free|pro|ultra`: initial tier hint
          (overridden by the actual tier from the API key)
        - `X-Orbyt-Scopes: scope1,scope2`: initial scope list (overridden
          by tier-mapped defaults)

        **Notifications:** notifications (no `id`) return HTTP 204 with
        an empty body. Successful requests return HTTP 200 with a
        JSON-RPC response body. Auth failures return HTTP 401, rate-limit
        failures HTTP 429 (legacy REST shape).

        **Cancellation note:** the Day 46 implementation suppresses the
        response (per MCP spec) but does NOT abort the upstream API
        call: the tool body keeps running and its result is silently
        dropped. True abort propagation through the SDK is future work.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JsonRpcRequest'
            examples:
              tools_list:
                summary: List the 6 MCP tools
                value:
                  jsonrpc: "2.0"
                  id: 1
                  method: "tools/list"
              tools_call_list_capabilities:
                summary: Call list_capabilities (tier-free tool; the hosted route still requires Bearer auth)
                value:
                  jsonrpc: "2.0"
                  id: 2
                  method: "tools/call"
                  params:
                    name: "list_capabilities"
              tools_call_analyze_compensation:
                summary: Estimate compensation for a role + city
                value:
                  jsonrpc: "2.0"
                  id: 3
                  method: "tools/call"
                  params:
                    name: "analyze_compensation"
                    arguments:
                      role: "ai-engineer"
                      city: "san-francisco"
                      experience: "senior"
              ping:
                summary: Liveness probe
                value:
                  jsonrpc: "2.0"
                  id: 4
                  method: "ping"
      parameters:
        - name: X-Orbyt-Tier
          in: header
          required: false
          schema:
            type: string
            enum: [free, pro, ultra]
          description: Initial tier hint (informational; the API key resolves the actual tier)
        - name: X-Orbyt-Scopes
          in: header
          required: false
          schema: { type: string }
          description: Initial scope list, comma-separated (overridden by tier-mapped defaults)
      responses:
        "200":
          description: JSON-RPC 2.0 response (success or structured error)
          headers:
            X-RateLimit-Limit: { schema: { type: string } }
            X-RateLimit-Remaining: { schema: { type: string } }
            X-RateLimit-Tier: { schema: { type: string } }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JsonRpcResponse'
        "204":
          description: "Notification (no `id` in request): empty body"
        "401":
          description: "Authentication failed: legacy REST error shape"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  message: { type: string }
                  docs: { type: string }
        "405":
          description: Method Not Allowed (only POST is accepted)
          headers:
            Allow: { schema: { type: string, enum: [POST] } }
        "429":
          description: "Rate limit exceeded: legacy REST error shape"
      security:
        - bearerAuth: []

    options:
      tags: [MCP]
      summary: CORS preflight
      responses:
        "204":
          description: CORS preflight OK
          headers:
            Access-Control-Allow-Origin: { schema: { type: string, enum: ["*"] } }
            Access-Control-Allow-Methods: { schema: { type: string } }
            Access-Control-Allow-Headers: { schema: { type: string } }
            Access-Control-Max-Age: { schema: { type: string } }
      security: []

  # ── MCP Endpoints (Pro+ tier required) ──────────────────────

  /api/mcp/intelligence/salary:
    get:
      operationId: mcpGetSalary
      summary: "MCP: Get salary estimate"
      description: Full salary estimate with compensation breakdown, adjusted for experience. Pro+ tier required.
      tags: [MCP]
      parameters:
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug (e.g., 'ai-engineer')
        - name: city
          in: query
          required: true
          schema: { type: string }
          description: City slug (e.g., 'san-francisco')
        - name: exp
          in: query
          required: false
          schema: { type: string, enum: [entry, mid, senior, staff], default: mid }
          description: Experience level
      responses:
        "200":
          description: Salary estimate with compensation breakdown
        "403":
          description: Requires Pro tier or above
      security:
        - bearerAuth: []

  /api/mcp/intelligence/roles:
    get:
      operationId: mcpSearchRoles
      summary: "MCP: Search roles"
      description: Search or list available salary roles with national medians. Pro+ tier required.
      tags: [MCP]
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
          description: Search keyword (e.g., 'engineer')
      responses:
        "200":
          description: List of matching roles
      security:
        - bearerAuth: []

  /api/mcp/intelligence/compare:
    get:
      operationId: mcpCompareSalaries
      summary: "MCP: Compare salaries"
      description: Side-by-side salary comparison. Two roles in one city (roleA + roleB + city) or one role across two cities (role + cityA + cityB). Pro+ tier required.
      tags: [MCP]
      parameters:
        - name: roleA
          in: query
          required: false
          schema: { type: string }
          description: First role slug (for role comparison)
        - name: roleB
          in: query
          required: false
          schema: { type: string }
          description: Second role slug (for role comparison)
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug (for role comparison)
        - name: role
          in: query
          required: false
          schema: { type: string }
          description: Role slug (for city comparison)
        - name: cityA
          in: query
          required: false
          schema: { type: string }
          description: First city slug (for city comparison)
        - name: cityB
          in: query
          required: false
          schema: { type: string }
          description: Second city slug (for city comparison)
      responses:
        "200":
          description: Side-by-side comparison with difference
      security:
        - bearerAuth: []

  /api/mcp/intelligence/range:
    get:
      operationId: mcpGetSalaryRange
      summary: "MCP: Get salary range"
      description: Percentile salary distribution (P10, P25, P50, P75, P90) with compensation breakdown. Pro+ tier required.
      tags: [MCP]
      parameters:
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug for COL-adjusted range
      responses:
        "200":
          description: Percentile distribution with comp breakdown
      security:
        - bearerAuth: []

  /api/mcp/intelligence/trends:
    get:
      operationId: mcpGetTrends
      summary: "MCP: Get salary trends"
      description: Quarterly historical salary trends with quarter-over-quarter growth. Pro+ tier required.
      tags: [MCP]
      parameters:
        - name: role
          in: query
          required: true
          schema: { type: string }
          description: Role slug
        - name: city
          in: query
          required: false
          schema: { type: string }
          description: City slug for COL-adjusted trends
      responses:
        "200":
          description: Quarterly salary history with growth
      security:
        - bearerAuth: []

components:
  parameters:
    # ─────────────────────────────────────────────────────────────────────
    # Reusable query parameters. Endpoints reference these via
    # `$ref: '#/components/parameters/<name>'`.
    # ─────────────────────────────────────────────────────────────────────
    CountryParam:
      name: country
      in: query
      required: false
      schema:
        $ref: '#/components/schemas/CountryCode'
      description: |
        ISO 3166-1 alpha-2 country code per RFC-006 Tier 1. Defaults to 'US'.
        Tier 1 supports US (default), GB (United Kingdom), and CA (Canada).
        Phase B adds additional countries via a separate RFC.

        **Locked behavior:**
        - Missing or 'US' → returns US data (backwards-compatible with all
          pre-RFC-006 callers).
        - 'GB' or 'CA' → currently returns 503 `dependency_unavailable` with
          a retry_after_seconds hint while the Tier 1 ingestion pipeline
          completes its operator-side rollout. Will flip to 200 with native
          + USD values once data lands.
        - Any other code → 400 `invalid_country_code`.

        Codes are case-insensitive on input; the response always shows
        upper-case ISO codes.

  responses:
    # ─────────────────────────────────────────────────────────────────────
    # Reusable error responses. Every path's 4xx/5xx references one of
    # these via `$ref: '#/components/responses/<name>'`. Phase 1 Day 6 work
    # rolls these into every endpoint definition; until then, a few paths
    # demonstrate the pattern (e.g., /salaries) and the rest will follow.
    # ─────────────────────────────────────────────────────────────────────
    BadRequest:
      description: |
        Invalid or missing parameter, malformed body, or unknown expand path.
        Error codes in this family include `parameter_missing`,
        `parameter_invalid`, `parameter_unknown`, `expand_path_unknown`,
        `expand_path_forbidden`, `expand_path_sunset`, `as_of_too_old`,
        `as_of_in_future`, and `invalid_country_code` (RFC-006 §9.1.
        Returned when the supplied `?country=` value is outside the
        Tier 1 enum).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    PermissionDenied:
      description: |
        Tier insufficient or scope insufficient for this endpoint or expand
        path, or the account has no active Intelligence subscription. Error
        codes in this family include `tier_insufficient`, `scope_insufficient`,
        `expand_path_forbidden`, and `subscription_required` (added 2026-06-09:
        a VALID API key whose account holds no trialing/active/past_due
        Intelligence subscription returns 403 `subscription_required` instead
        of a misleading 401, with billing dashboard and pricing links in
        the message (a free key requires no card; paid plans bill immediately).
        Missing/invalid keys still return 401).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Role, city, data point, or other resource was not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: Idempotency-key conflict or concurrent-write conflict.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnprocessableEntity:
      description: Request well-formed but semantically invalid (e.g., comparing identical roles).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimitExceeded:
      description: Tier quota exceeded. See Retry-After header for cool-down window.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until quota resets.
        X-RateLimit-Limit:
          schema:
            type: integer
        X-RateLimit-Remaining:
          schema:
            type: integer
        X-RateLimit-Tier:
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    InternalError:
      description: Unexpected server error. Never the customer's fault. Please report with request_id.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ServiceUnavailable:
      description: |
        Temporary outage or upstream dependency failure. Retry per Retry-After hint.
        Error codes: `service_unavailable`, `dependency_unavailable`. The
        latter is returned for `?country=GB` and `?country=CA` requests
        until Tier 1 ingestion completes its operator-side rollout per
        RFC-006 (the response includes `retry_strategy: retry_after_seconds`
        and `retry_hint.after_seconds: 86400`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    # ═════════════════════════════════════════════════════════════════════
    # JSON-RPC 2.0 SCHEMAS (Phase 4 Day 51), used by /api/v1/intelligence/mcp
    #
    # These mirror the JSON-RPC 2.0 spec
    # (https://www.jsonrpc.org/specification). The MCP HTTP route is
    # exempt from the locked RFC-001 envelope because it speaks JSON-RPC
    # per the MCP spec.
    # ═════════════════════════════════════════════════════════════════════

    JsonRpcId:
      oneOf:
        - type: string
        - type: integer
        - type: "null"
      description: |
        Request id per JSON-RPC 2.0 §4. String, number, or null. Notifications
        omit the id entirely (which is distinct from null).

    JsonRpcRequest:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
        id:
          $ref: '#/components/schemas/JsonRpcId'
        method:
          type: string
          minLength: 1
          examples:
            - "tools/list"
            - "tools/call"
            - "ping"
            - "initialize"
            - "notifications/initialized"
            - "notifications/cancelled"
        params:
          description: |
            Method-specific parameters. For `tools/call`, this is
            `{name: string, arguments: object}` where `arguments` matches
            the tool's locked inputSchema.

    JsonRpcSuccessResponse:
      type: object
      required: [jsonrpc, id, result]
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
        id:
          $ref: '#/components/schemas/JsonRpcId'
        result:
          description: |
            Method-specific result. For `tools/call`, this is
            `{content: [{type: "text", text: <stringified DecisionReadyResponse>}]}`
            per the MCP spec.

    JsonRpcErrorResponse:
      type: object
      required: [jsonrpc, id, error]
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
        id:
          $ref: '#/components/schemas/JsonRpcId'
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: integer
              description: |
                JSON-RPC 2.0 standard codes:
                  -32700: Parse error (malformed JSON)
                  -32600: Invalid Request
                  -32601: Method not found
                  -32602: Invalid params
                  -32603: Internal error
                MCP-specific codes:
                  -32000: Application error
                  -32001: Tier or scope insufficient (custom MCP)
                  -32002: Tool registered but not implemented
            message: { type: string }
            data:
              description: |
                Optional structured error data. For -32001, includes
                `{reason: "tier_insufficient" | "scope_insufficient", missing?: {...}}`.

    JsonRpcResponse:
      oneOf:
        - $ref: '#/components/schemas/JsonRpcSuccessResponse'
        - $ref: '#/components/schemas/JsonRpcErrorResponse'

    Tier:
      type: string
      enum: [free, pro, ultra]
      description: Subscription tier resolved from the calling key.

    CountryCode:
      type: string
      enum: [US, GB, CA]
      description: |
        ISO 3166-1 alpha-2 country code. RFC-006 Tier 1 = US (United States),
        GB (United Kingdom), CA (Canada). The default value across the API
        is 'US' for backwards compatibility with all pre-RFC-006 callers.
        Phase B introduces additional countries via a separate RFC; until
        then, any other code returns 400 `invalid_country_code`.

    Scope:
      type: string
      enum:
        - intelligence:read
        - intelligence:write
        - intelligence:admin
        - intelligence:lineage
        - compensation:read
        - skills:read
        - market:read
        - company_data:read
      description: |
        REST scopes per RFC-002 §6.1. New keys default to
        `intelligence:read`. Customers explicitly grant additional scopes
        via the Intelligence dashboard. Engine-specific scopes
        (`compensation:read`, `skills:read`, etc.) gate per-engine
        endpoints + expand paths.

    SourceBreakdown:
      type: object
      description: |
        Per-source weights for any blended estimate. Sum of values is
        1.0 ± 0.01. Common keys: `bls_oes`, `h1b_lca`, `pay_transparency`
        (US reconciled data), plus Tier 1 international keys such as
        `uk_ons_ashe` and `ca_opengov_wages`. Synthetic estimates use
        sentinel keys like `computed_from_role_baseline: 1.0` or
        `synthetic_compound_growth: 1.0` to be honest about provenance.
      additionalProperties:
        type: number
      example:
        bls_oes: 0.4
        h1b_lca: 0.3
        pay_transparency: 0.3

    Estimate:
      type: object
      required:
        - low
        - median
        - high
        - currency
        - period
        - sample_size
        - confidence_level
        - confidence_lower
        - confidence_upper
        - methodology_version
        - source_breakdown
        - disagreement_flag
        - data_point_id
        - as_of
      description: |
        locked estimate shape. Every numeric forecast or aggregate
        in the Intelligence API uses this shape so customers can rely on
        consistent disclosure of sample size, confidence, methodology,
        source weighting, and time-of-record.
      properties:
        low: { type: integer, description: 25th percentile / lower bound }
        median: { type: integer, description: 50th percentile / point estimate }
        high: { type: integer, description: 75th percentile / upper bound }
        currency: { type: string, example: USD }
        period:
          type: string
          enum: [annual, monthly, hourly]
        sample_size:
          type: integer
          minimum: 0
          description: |
            Number of underlying data points contributing to this estimate.
            Required to be present; a value of 0 with a low confidence flag
            indicates a synthetic or modelled estimate (still served, but
            customers know it's not direct measurement).
        sample_size_attribution:
          type: string
          enum: [real, mixed, placeholder_only]
          description: |
            Whether the `sample_size` figure is load-bearing statistically
            (every contributor's count is a real survey cell), mixed
            (some real + some structural placeholder / geographic
            fan-out), or placeholder-only (no real cell counts;
            structural placeholders only, e.g., ESDC publishes no sample
            size; UK Visa uses a legal-floor placeholder; HMRC RTI
            divides one geographic count across N applicable roles).
            Treat `sample_size` at face value only when this is `real`.
            For `mixed` or `placeholder_only`, consult the methodology
            paper for source-level disclosure.

            Optional for back-compat. Absent === assume `real` (the
            existing Phase 2A US-only assumption; all US sources
            publish real sample sizes).
        confidence_level:
          type: number
          minimum: 0
          maximum: 1
          description: Statistical confidence level (typically 0.95).
        confidence_lower: { type: integer }
        confidence_upper: { type: integer }
        methodology_version:
          type: string
          pattern: "^\\d{4}\\.\\d+(-[a-z0-9-]+)?$"
          description: |
            Semver-like methodology tag. Format `YYYY.N` for stable releases
            (e.g., `2026.2`); `YYYY.N-suffix` when a route uses a variant
            methodology (e.g., `2026.2-synthetic-cagr` for the projections
            engine, `2026.2-community` for crowd-sourced aggregates).
        source_breakdown:
          $ref: '#/components/schemas/SourceBreakdown'
        disagreement_flag:
          type: boolean
          description: |
            True when constituent sources disagree by >25% (RFC-002 B1
            cross-source reconciliation rule). When true, the estimate is
            still returned but a `warnings` array on the envelope lists
            the affected data points.
        data_point_id:
          type: string
          description: |
            Stable lookup ID for the lineage API. Format
            `<entity>:<location>:<field>:<as_of>`. Pass to
            `/api/v1/intelligence/lineage/:data_point_id` (Phase 3 B5)
            to retrieve full source provenance.
          example: "ai-engineer:san-francisco:median_base:2026-Q2"
        as_of:
          type: string
          format: date
          description: |
            Effective date for this number. Defaults to the current quarter
            unless `?as_of=YYYY-MM-DD` was passed (B4 universal time-travel).

    Attribution:
      type: object
      required:
        - source
        - url
        - methodology
        - methodology_version
        - updated
        - version
        - as_of
      description: Locked attribution block on every Intelligence response.
      properties:
        source:
          type: string
          enum: ["Orbyt Intelligence"]
        url:
          type: string
          format: uri
          enum: ["https://www.orbytjobs.ai/intelligence"]
        methodology:
          type: string
          format: uri
          enum: ["https://www.orbytjobs.ai/orbyt-intelligence/methodology"]
        methodology_version:
          type: string
          pattern: "^\\d{4}\\.\\d+(-[a-z0-9-]+)?$"
        updated:
          type: string
          format: date
        version:
          type: string
          enum: [v1]
        as_of:
          type: string
          format: date

    RequestMeta:
      type: object
      required: [id, endpoint, duration_ms, tier, scopes]
      description: Per-request observability metadata. Mirrors `x-request-id` header.
      properties:
        id:
          type: string
          pattern: "^req_[0-9a-f]{16}$"
          description: Stripe-style request id, also returned as `x-request-id` header.
          example: req_1a2b3c4d5e6f7g8h
        endpoint:
          type: string
          description: Path of the called endpoint.
          example: /api/v1/intelligence/salaries/calculate
        duration_ms:
          type: integer
          minimum: 0
          description: Server-side processing time. Excludes network.
        tier:
          $ref: '#/components/schemas/Tier'
        scopes:
          type: array
          description: Scopes the calling key holds.
          items:
            $ref: '#/components/schemas/Scope'

    Pagination:
      type: object
      required: [has_more, next_cursor, previous_cursor, total_count]
      description: |
        Cursor pagination per RFC-002 §2. Cursors are opaque server-signed
        tokens; clients must pass them back verbatim. `total_count` is
        nullable: included on catalog endpoints (≤10K rows), null on
        high-volume endpoints unless `?include_total=true`.
      properties:
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
          description: Pass to `?starting_after=` for the next page.
        previous_cursor:
          type: string
          nullable: true
          description: Pass to `?ending_before=` for the previous page.
        total_count:
          type: integer
          nullable: true
          minimum: 0

    ResourceEnvelope:
      type: object
      required: [data, attribution, request]
      description: Single-resource success envelope (RFC-001 §2.1).
      properties:
        data: { type: object }
        attribution:
          $ref: '#/components/schemas/Attribution'
        request:
          $ref: '#/components/schemas/RequestMeta'
        warnings:
          type: array
          description: |
            Optional non-fatal warnings (e.g., deprecated expand paths used,
            disagreement_flag set). Customers should surface these to users
            but the response is otherwise valid.
          items:
            type: object
            properties:
              code: { type: string }
              path: { type: string }
              message: { type: string }
              sunset_at: { type: string, format: date, nullable: true }
              replacement_path: { type: string, nullable: true }

    ListEnvelope:
      type: object
      required: [data, pagination, attribution, request]
      description: List success envelope (RFC-001 §2.2).
      properties:
        data:
          type: array
          items:
            type: object
        pagination:
          $ref: '#/components/schemas/Pagination'
        attribution:
          $ref: '#/components/schemas/Attribution'
        request:
          $ref: '#/components/schemas/RequestMeta'

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message, doc_url, request_id, retry_strategy]
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - conflict_error
                - rate_limit_error
                - unprocessable_entity_error
                - api_error
                - service_unavailable
              description: |
                Coarse error category, one of nine values locked at Phase 1 Day 1.
                New error situations get new `code` values under an existing
                `type`, never new `type` values.
            code:
              type: string
              description: |
                Specific error code in snake_case. Permanent, never deleted,
                only deprecated. Examples: parameter_missing, role_not_found,
                tier_insufficient, subscription_required, expand_path_unknown,
                idempotency_key_conflict.
              example: parameter_invalid
            param:
              type: string
              nullable: true
              description: Set when the error is parameter-specific.
              example: role
            message:
              type: string
              description: Plain-English explanation. Agents may quote verbatim.
              example: "Role slug 'engineer' is ambiguous. Use a canonical role ID like 'role:ai-engineer'."
            doc_url:
              type: string
              format: uri
              description: Permanent URL to the error code's documentation page.
              example: https://www.orbytjobs.ai/intelligence/api/errors/parameter_invalid
            request_id:
              type: string
              pattern: "^req_[0-9a-f]{16}$"
              description: |
                Mirrors the `x-request-id` response header. Searchable in the
                customer log explorer. Format `req_<16hex>` (Stripe-style).
              example: req_1a2b3c4d5e6f7g8h
            retry_strategy:
              type: string
              enum:
                - do_not_retry
                - retry_after_seconds
                - retry_with_clarification
                - use_alternative_tool
              description: |
                Decision-ready retry guidance per RFC-001. Tells clients (and
                MCP agents) how to respond to the error without parsing
                message text.
            retry_hint:
              type: object
              nullable: true
              description: Structured retry detail when retry_strategy needs it.
              properties:
                after_seconds:
                  type: integer
                  description: Seconds to wait before retry. Set when retry_strategy = retry_after_seconds.
                alternative_tool:
                  type: string
                  description: Suggested tool / endpoint when retry_strategy = use_alternative_tool.
                clarification_needed:
                  type: string
                  description: What the client needs to clarify when retry_strategy = retry_with_clarification.
            follow_up_suggestions:
              type: array
              items:
                type: string
              description: |
                Decision-ready next-step suggestions an agent or human can act
                on. Surfaces in MCP responses verbatim.
              example:
                - "Try role: role:ai-engineer"
                - "Use /api/v1/intelligence/salaries/search?q=engineer to find canonical IDs"

    SalaryResponse:
      type: object
      properties:
        role:
          type: object
          properties:
            slug: { type: string }
            title: { type: string }
        city:
          type: object
          properties:
            slug: { type: string }
            name: { type: string }
            state: { type: string }
            costOfLivingMultiplier: { type: number }
        salary:
          type: object
          properties:
            low: { type: number }
            median: { type: number }
            high: { type: number }
            currency: { type: string, example: USD }
            period: { type: string, example: annual }
        totalCompensation:
          type: object
          description: Structured breakdown of total compensation components
          properties:
            base: { type: number, description: Median base salary }
            equity: { type: number, description: Median annual equity vesting value }
            bonus: { type: number, description: Median annual bonus amount }
            signing: { type: number, description: Typical signing bonus for new hires }
            total: { type: number, description: Sum of base + equity + bonus }
        companySizeBands:
          type: object
          description: Salary estimates by employer headcount
          properties:
            startup:
              type: object
              properties:
                median: { type: number }
                multiplier: { type: number, description: Multiplier vs. national median (e.g., 0.85) }
            growth:
              type: object
              properties:
                median: { type: number }
                multiplier: { type: number }
            scaleup:
              type: object
              properties:
                median: { type: number }
                multiplier: { type: number }
            publicCo:
              type: object
              properties:
                median: { type: number }
                multiplier: { type: number }
        remoteAdjustment:
          type: object
          description: Remote work salary differential
          properties:
            multiplier: { type: number, description: Remote pay as fraction of on-site (e.g., 0.90 = 10% less) }
            remoteMedian: { type: number, description: Estimated remote salary }
        blsSocCode:
          type: string
          description: BLS Standard Occupational Classification code used as baseline
          example: "15-2051"
        communityReported:
          type: object
          nullable: true
          description: Aggregated anonymous salary submissions (null if fewer than 5 exist for this role)
          properties:
            count: { type: integer, description: Number of submissions }
            baseSalary:
              type: object
              properties:
                p25: { type: number }
                median: { type: number }
                p75: { type: number }
            equity:
              type: object
              properties:
                p25: { type: number }
                median: { type: number }
                p75: { type: number }
            totalComp:
              type: object
              properties:
                p25: { type: number }
                median: { type: number }
                p75: { type: number }
            source: { type: string }
            submitUrl: { type: string }
        attribution:
          $ref: '#/components/schemas/Attribution'

    ObservedFigure:
      type: object
      description: |
        The wage the US Bureau of Labor Statistics actually published for the
        occupation a role maps to, or a labelled reason there is none. This is
        the only figure in the response that was observed rather than computed,
        which is why it lives in its own block with its own citation.

        BLS publishes per OCCUPATION, not per job title, and several distinct
        titles can share one occupation code. Every field that makes that
        granularity legible (`occupation_code`, `occupation_title`, `metro`,
        `employed_workers`, `release`) is always present on an available figure,
        and `note` states it in prose. A bare number here would be a wrong claim
        carrying a government citation.
      oneOf:
        - type: object
          required: [available, scope, source, release, occupation_code, median]
          properties:
            available: { type: boolean, enum: [true] }
            scope:
              type: string
              enum: [metro, national]
              description: |
                `metro` when BLS published a cell for this metropolitan area.
                `national` when it did not and the country-wide cell is being
                offered instead. A national figure is never passed off as local.
            source: { type: string, enum: [bls_oes] }
            release: { type: string, example: "May 2025 release" }
            occupation_code: { type: string, example: "29-1141" }
            occupation_title: { type: string, nullable: true, example: "Registered Nurses" }
            metro:
              type: string
              nullable: true
              example: "Detroit-Warren-Dearborn, MI"
              description: Published metro title. Null on a national figure.
            via_shared_metro:
              type: boolean
              description: |
                True when the requested city is served by a metro named for a
                different principal city (Newark reading the
                New York-Newark-Jersey City metro). The figure is correct for
                the metro; say which metro.
            percentile_10:
              type: number
              nullable: true
              description: |
                BLS OES 10th percentile. NULLABLE while 25/50/75 are not, and
                the asymmetry is the source's: BLS top-codes a tail far more
                often than the middle (`#` = an annual wage at or above
                $239,200, with no printable value). Null means BLS printed
                nothing here and must never be rendered as zero. The field is
                present-with-null rather than omitted so a caller can tell
                "suppressed" from "not carried".
            percentile_25: { type: number }
            median: { type: number }
            percentile_75: { type: number }
            percentile_90:
              type: number
              nullable: true
              description: BLS OES 90th percentile. Nullable for the same reason as `percentile_10`.
            currency: { type: string, enum: [USD] }
            period: { type: string, enum: [year] }
            employed_workers:
              type: number
              description: |
                BLS employment count for the occupation in this area.
                Deliberately not called `sample_size`: it counts employed
                workers, not survey responses, and the two are different claims.
            url: { type: string, format: uri }
            citation:
              type: string
              example: "US Bureau of Labor Statistics, Occupational Employment and Wage Statistics (May 2025 release), SOC 29-1141, Detroit-Warren-Dearborn, MI metropolitan area. Based on 44,310 employed workers."
            note: { type: string }
        - type: object
          required: [available, reason, detail]
          properties:
            available: { type: boolean, enum: [false] }
            reason:
              type: string
              enum: [role_not_mapped_to_occupation, no_published_figure]
            detail:
              type: string
              description: Plain-language form of `reason`, for a caller rendering text.

    CalculateResponse:
      type: object
      properties:
        role:
          type: object
          properties:
            slug: { type: string }
            title: { type: string }
        city:
          type: object
          properties:
            slug: { type: string }
            name: { type: string }
            state: { type: string }
            cost_of_living_multiplier: { type: number }
        experience:
          type: object
          properties:
            level: { type: string, enum: [entry, mid, senior, staff] }
            multiplier: { type: number }
        estimate:
          $ref: '#/components/schemas/Estimate'
        totalCompensation:
          type: object
          properties:
            base: { type: number }
            equity: { type: number }
            bonus: { type: number }
            signing: { type: number }
            total: { type: number }
        comparison:
          type: object
          properties:
            nationalMedian: { type: number }
            vsNationalPct: { type: number }
        citation:
          type: string
          description: |
            Cite-ready sentence, safe to quote verbatim. It names the figure as
            a model in its own clause, because a summariser drops adjectives
            before it drops sentences and this figure must never survive one
            hop as an observed wage.
          example: "Orbyt Intelligence MODELS a senior-level AI Engineer in San Francisco, CA at $298,000 base and approximately $412,000 total compensation. This is a computed estimate, not an observed wage: it is not drawn from BLS, from any wage survey, or from reported pay, and it must not be cited as one."
        observed:
          $ref: '#/components/schemas/ObservedFigure'
        assumptions:
          type: array
          items: { type: string }
          description: Methodology disclosure array
        shareableUrl:
          type: string
          format: uri
        pageUrl:
          type: string
          format: uri
        attribution:
          $ref: '#/components/schemas/Attribution'

    # NOTE: The locked Attribution schema is defined above (Phase 1 Day 6
    # polish per RFC-001). The legacy minimal Attribution definition that
    # used to live here was superseded.

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Orbyt Intelligence API key (starts with `intelligence_`). Required on all
        Salary endpoints. Free: 60 req/min, 1,000 requests a month. Pro: 300 req/min ($99/mo), full history + MCP server.
        Ultra: 1,500 req/min ($199/mo) + the company leveling catalog. Create an account and generate a key at
        https://www.orbytjobs.ai/intelligence/dashboard.

security: []
