# docs/openapi.yaml  ← SINGLE SOURCE OF TRUTH
#
# VATBuild v1 REST API specification.
#
# ── Keeping this spec in sync ─────────────────────────────────────────────────
# This file is the machine-readable contract for /api/v1/. Whenever you change
# a v1 route schema, update this file too:
#
#   • New or renamed request field?  → update the matching schema under
#     components/schemas/ (e.g. CheckLineItemItem, InlineProjectContext).
#   • New error code?               → add it to the relevant components/responses/
#     entry and to the endpoint's responses section.
#   • New endpoint or path?         → add a new path block under `paths:`.
#
# docs/public/openapi.yaml is a SYMLINK pointing to this file (../openapi.yaml).
# Do NOT edit docs/public/openapi.yaml directly — always edit this file.
# The validate-spec CI job diffs the two paths and fails if they ever diverge.
#
# Validate after every change:
#   npx @redocly/cli lint docs/openapi.yaml
#
# The `validate-spec` CI workflow runs this check automatically on every push.
# The Zod source of truth for POST /api/v1/check-line-item is:
#   server/routes/v1/checkLineItem.schema.ts
# ─────────────────────────────────────────────────────────────────────────────

openapi: 3.1.0

info:
  title: VATBuild REST API
  version: "1"
  description: |
    VATBuild's v1 REST API for UK construction VAT reclaim analysis, based on HMRC Notice 708 rules.

    ## ESM esmStatus v2 → v3 migration

    The `esmStatus` field on `POST /api/v1/check-line-item` changed its value set between
    the v2 and v3 extraction schemas:

    | v2 value (stale)       | v3 replacement            | Meaning                                  |
    |------------------------|---------------------------|------------------------------------------|
    | `"supply_and_install"` | `"qualifying_esm"`        | ESM supply-and-install item              |
    | `"none"`               | `null` (omit the field)   | Not an ESM item                          |
    | _(not present)_        | `"ancillary_to_esm"`      | Item ancillary to an ESM supply line     |

    **What happens if you send a v2 value:** The API still accepts `"none"` and
    `"supply_and_install"` but treats them as `null` (non-ESM). The `ok: true` response will
    include a `warnings` array containing a `{ code: "ESM_STATUS_V2_VALUE", message: "..." }`
    entry. ESM supply-and-install items sent with `"supply_and_install"` will be **misclassified
    as non-ESM** until the pipeline is updated.

    **Action required:** Update your extraction pipeline to emit `"qualifying_esm"` for ESM
    supply-and-install lines and `null` (or omit the field) for non-ESM lines. Check every
    `ok: true` response for a non-empty `warnings` array to confirm the migration is complete.

    ## Authentication

    Most endpoints require a **Bearer API key** (`Authorization: Bearer vb_live_<64hex>`)
    or an authenticated **session cookie** (browser clients).

    Endpoints that accept but do not require authentication (`GET /api/v1/reference/work-packages`)
    are marked with an empty security override (`security: []`).

    Fully public endpoints (`POST /api/v1/check-line-item`, `GET /api/v1/reference/routes`)
    carry no security requirement.

    ### Obtaining a Bearer API key

    Keys can be obtained in three ways:

    1. **MCP device-code flow (AI agents):** Call the `authenticate_account` MCP tool → receive
       a `request_id` → poll `poll_authentication` every 3–5 s → when the user clicks the magic
       link, the response contains `{ status: "ready", key_retrieval_url }` → the user opens
       that URL in their browser to retrieve the API key, then uses it as the Bearer token.
       Keys created this way carry all scopes.
    2. **Account creation:** Call the `create_account` MCP tool → the `apiKey` field in the
       response is immediately usable.
    3. **Web app:** Copy from **Account → API Keys** in the VATBuild web app.

    ## Scopes

    API keys carry one or more of the following scopes:

    | Scope | Required by |
    |---|---|
    | `projects:write` | `POST /api/v1/projects` |
    | `projects:read` | `GET /api/v1/projects/:id/estimate` |
    | `invoices:write` | `POST /api/v1/invoices`, `POST /api/v1/invoices/lock` |
    | `jobs:read` | `GET /api/v1/jobs/:jobId` |

    Session-authenticated principals implicitly hold all scopes.

    `GET /api/v1/projects` and `GET /api/v1/projects/:id/line-items` require
    authentication only — no additional scope claim is enforced for these read-only endpoints.

    ## Response envelope

    Every response uses one of two shapes — defined as reusable components:

    ```json
    { "ok": true,  "data": { ... } }          // OkEnvelope + endpoint-specific data
    { "ok": false, "error": { "code": "...", "message": "..." } }  // ErrorEnvelope
    ```

    Machine clients should branch on `ok`, then on `error.code` for errors.
    Error codes are stable within `/api/v1/` — they will not change without a version bump.

    ## Rate limiting

    Rate-limited endpoints return **HTTP 429** with a `Retry-After` header (seconds).

    | Limiter | Applied to |
    |---|---|
    | `restComputeLimiter` | `POST /api/v1/check-line-item` |
    | `v1WriteLimiter` | `POST /api/v1/projects`, `POST /api/v1/invoices` |
    | `v1ActionLimiter` | All authenticated reads, `POST /api/v1/invoices/lock` |
    | `publicScrapeLimiter` | `GET /api/v1/reference/*` |

  contact:
    name: VATBuild Support
    url: https://vatbuild.com
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: https://vatbuild.com
    description: Production

tags:
  - name: Classification
    description: Stateless VAT classification (no auth, no persistence)
  - name: Projects
    description: Project lifecycle management
  - name: Invoices
    description: Invoice submission and assessment locking
  - name: Jobs
    description: Async job status polling
  - name: Reference
    description: Read-only reference data for AI-agent discovery

# ── Security schemes ──────────────────────────────────────────────────────────

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key in the format `vb_live_<64 hex characters>`.
        Pass as `Authorization: Bearer vb_live_...`.
        The key must carry the relevant scope for the endpoint being called.

  # ── Shared response headers ─────────────────────────────────────────────────

  headers:
    RetryAfter:
      description: Number of seconds to wait before retrying.
      schema:
        type: integer
        example: 60

  # ── Reusable schemas ────────────────────────────────────────────────────────

  schemas:

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

    OkEnvelope:
      type: object
      required: [ok]
      description: |
        Base shape for every successful response. Endpoint-specific responses
        extend this via `allOf` and add a `data` property.
      properties:
        ok:
          type: boolean
          enum: [true]
          description: Always `true` for a successful response.

    ErrorEnvelope:
      type: object
      required: [ok, error]
      description: |
        Base shape for every error response. Endpoint-specific error schemas
        extend this via `allOf` and narrow `error.code` to a specific enum value.
        Machine clients should branch on `error.code` — values are stable within v1.
      properties:
        ok:
          type: boolean
          enum: [false]
          description: Always `false` for an error response.
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Machine-readable error identifier. Stable within /api/v1/.
            message:
              type: string
              description: |
                Human-readable detail. For `INVALID_INPUT` this is a semicolon-separated
                list of field-level Zod validation errors.

    # ── Shared primitive types ───────────────────────────────────────────────

    JobStatus:
      type: string
      enum: [queued, active, completed, failed]
      description: |
        Current state of an async extraction job.
        - `queued` — waiting in the pg-boss queue.
        - `active` — worker has picked it up and is running.
        - `completed` — finished successfully; `data.result` is populated.
        - `failed` — terminal failure; `data.error` is populated.

    # ── Project context ─────────────────────────────────────────────────────

    InlineProjectContext:
      type: object
      description: |
        Human-readable project-setup inputs that feed `deriveClassificationContext()`.
        Callers supply these fields rather than raw internal flags.
      properties:
        projectType:
          type: string
          description: |
            Free-text project description / AI classification label.
            Examples: "New build dwelling", "Residential conversion", "Renovation".
          example: New build dwelling
        newDwelling:
          type: string
          enum: [yes, no, Yes, No]
          description: Whether this project produces a new dwelling (HMRC eligibility).
          example: "yes"
        buildingType:
          type: string
          description: |
            Building classification — used to detect 431C conversion eligibility.
            Examples: "barn conversion", "office conversion".
          example: barn conversion
        claimantRoute:
          type: string
          enum:
            - self_build_431nb
            - self_build_431c
            - self_build_standard
            - self_build_reduced
          description: |
            Claimant route from the project profile wizard. Accepted values for
            `POST /api/v1/check-line-item`:
              - `self_build_431nb` — new-build self-build; services zero-rated,
                materials 20% reclaimable via VAT431NB form.
              - `self_build_431c` — qualifying conversion; services 5% reclaimable
                via VAT431C form.
              - `self_build_standard` — standard renovation/extension; no
                zero-rating or refund relief applies.
              - `self_build_reduced` — renovation of an empty dwelling; 5% reduced
                rate at source, no refund scheme.

            The values `developer` and `contractor` are recognised internally but
            are not yet accepted as input — submitting either returns HTTP 400
            with `error.code: "ROUTE_NOT_SUPPORTED"`. They are planned for a
            future release.

            The value `reverse_charge_cis` is a **stored-only** value that may
            appear on line items returned by `GET /api/v1/projects/:id/line-items`
            but is never a valid input to `POST /api/v1/check-line-item`.
            Submitting it returns HTTP 400 with `error.code: "ROUTE_NOT_SUPPORTED"`.
          example: self_build_431nb

    # ── Check line item ─────────────────────────────────────────────────────

    CheckLineItemItem:
      type: object
      description: Fields of the invoice line item to classify.
      properties:
        lineText:
          type: string
          default: ""
          description: Human-readable description of the line item.
          example: Structural brickwork and blockwork
        netAmount:
          type: string
          default: "0"
          description: Net amount (ex-VAT) as a decimal string.
          example: "1000.00"
        vatCharged:
          type: string
          default: "0"
          description: VAT charged as a decimal string.
          example: "200.00"
        supplyType:
          type: [string, "null"]
          description: |
            Nature of supply from the AI extraction. Null = unknown (heuristics take over).

            **Canonical values:**
            - `materials` — goods only, no installation (merchant purchase)
            - `labour` — labour-only service
            - `subcontractor` — subcontracted works billed as a package
            - `supply_and_install` — goods supplied and fitted by the same contractor
            - `installation_service` — installation of a specific product type (e.g. ESM)
            - `professional_services` — design, surveying, PM, or similar professional fees

            **Accepted aliases** (mapped to the nearest canonical class):
            - `materials_only` → same as `materials`
            - `goods` → same as `materials`
            - `service` → same as `labour`
            - `install` → same as `installation_service`
          example: materials
        identifierName:
          type: [string, "null"]
          description: |
            Taxonomy identifier name as matched by the AI.
            Used for VAT rate matrix lookup. Null = no match.
          example: Structural works
        vatComplexTypeHint:
          type: [string, "null"]
          description: |
            Complex-VAT type hint set by the AI or a prior classification.
            e.g. `esm_install`, `scaffolding`, `fitted_furniture`. Null = none.
        esmStatus:
          type: [string, "null"]
          description: |
            ESM status from the AI. One of: `qualifying_esm`, `ancillary_to_esm`, or null.

            **v2 stale values (deprecated):** The values `"none"` and `"supply_and_install"`
            were used in the v2 extraction schema. Sending either value is still accepted but
            will trigger an `ESM_STATUS_V2_VALUE` warning in the response `warnings` array, and
            the value is treated as `null` (non-ESM). Update your extraction pipeline to use
            `"qualifying_esm"` for ESM supply-and-install items, or omit the field / pass `null`
            for non-ESM items.
        relation:
          type: [string, "null"]
          description: |
            AI relation label. `"Likely not claimable"` marks an item as soft-excluded.
        confirmedAnswer:
          oneOf:
            - type: boolean
            - type: string
            - type: "null"
          description: |
            Confirmed answer to a complex-VAT question.
            `true`/`false` for yes/no, or a string third-option value.
            Valid string values include: `"supply_only"`, `"construction"`,
            `"materials_only"`, `"contractor_installed"`, `"not_sure"`,
            `"bathroom_wc_qualifying"`.
        esmInvoiceCtx:
          type: boolean
          default: false
          description: |
            Invoice-level ESM context: `true` when the invoice contains ≥1 ESM install line.
            Defaults to `false` (safe — no injection without explicit opt-in).
        saiInvoiceCtx:
          type: boolean
          default: false
          description: |
            Invoice-level supply-and-install context: `true` when the invoice contains ≥1
            non-ESM install line on a 431NB/431C project.

    ClassificationContext:
      type: object
      description: |
        Derived VAT treatment context returned by the estimate endpoints.
        Computed from InlineProjectContext inputs via `deriveClassificationContext()`.
      required:
        - isHmrcEligible
        - isConversion
        - isDeveloper
        - isContractor
        - isNonSchemeSelfBuild
        - claimantRoute
        - expectedServiceRate
      properties:
        isHmrcEligible:
          type: boolean
          description: Project uses the 431NB or 431C HMRC self-build refund scheme.
        isConversion:
          type: boolean
          description: 431C conversion specifically (non-residential → residential).
        isDeveloper:
          type: boolean
          description: Developer input-tax recovery route (VAT return, not 431 form).
        isContractor:
          type: boolean
          description: Contractor input-tax recovery route (VAT return, not 431 form).
        isNonSchemeSelfBuild:
          type: boolean
          description: |
            Self-builder route via `self_build_standard` or `self_build_reduced` —
            NOT an HMRC 431NB/431C claim scheme.
        claimantRoute:
          type: string
          description: Raw claimantRoute string from the project profile.
        expectedServiceRate:
          type: [integer, "null"]
          enum: [0, 5, 20, null]
          description: |
            VAT rate (%) that qualifying contractor services should be charged at:
            - `0` — 431NB new build (zero-rated qualifying construction services)
            - `5` — 431C conversion / self_build_reduced (reduced rate at source)
            - `20` — self_build_standard (renovation/extension, no relief scheme)
            - `null` — Developer / Contractor routes (concept does not apply)
        matrixRate:
          type: [number, "null"]
          description: |
            Per-item rate from the VAT rate matrix resolved for the identifier × route combination.
            Overrides `expectedServiceRate` when present. Absent = no matrix entry.

    VatOutcome:
      type: object
      required: [claimRoute, claimantReclaimBasis, supplierInvoiceRate, reviewStatus, noActionNote, firedRuleId]
      description: Raw VAT routing outcome from the classification engine.
      properties:
        claimRoute:
          type: string
          enum:
            - hmrc_refund
            - input_tax
            - zero_at_source
            - reduced_at_source
            - supplier_correction
            - undercharged_vat
            - not_reclaimable
            - input_tax_blocked
            - pending
            - pending_complex_answer
          description: |
            VAT routing outcome returned by the check_line_item tool. Ten values are reachable
            here. Five additional stored-only values (full_hmrc_refund, esm_zero_rate,
            split_required, reverse_charge_cis, outside_scope) may appear on items returned by
            list_line_items but are never returned by check_line_item.
        claimantReclaimBasis:
          type: [string, "null"]
          description: Basis rate (%) on which the claimant reclaims, as a string. e.g. "20", "5".
        supplierInvoiceRate:
          type: [string, "null"]
          description: Rate (%) the supplier should have charged on the invoice. e.g. "0", "5".
        reviewStatus:
          type: string
          enum: [pending, approved, no_action]
          description: Current review status of the classification.
        noActionNote:
          type: [string, "null"]
          description: |
            Explanatory note from the classification engine. Always present. Non-null only on
            pending_complex_answer outcomes, where it states the question to resolve or flags a
            potential overcharge. Null on every other outcome, including all no_action ones.
            Never infer reviewStatus from this field.
        firedRuleId:
          type: string
          description: |
            Stable opaque identifier of the rule that produced this outcome.
            The same rule always returns the same value, but the format is not
            meaningful externally — do not parse or pattern-match it. Branch on
            equality only (e.g. to deduplicate advisory signals).
          example: "rule_a8f3c2d1e4b7f209"

    CheckLineItemWarning:
      type: object
      required: [code, message]
      description: |
        A non-fatal warning attached to an `ok: true` response when the request
        contained stale or deprecated input values that were accepted but may
        have caused misclassification. Machine clients should inspect the `warnings`
        array on every response and update their extraction pipelines accordingly.
      properties:
        code:
          type: string
          enum:
            - ESM_STATUS_V2_VALUE
            - ACTUAL_RATE_INFORMATIONAL_ONLY
          description: |
            Machine-readable warning code. Stable within /api/v1/. Known values:
            - `ESM_STATUS_V2_VALUE` — `esmStatus` contained a stale v2 value
              (`"none"` or `"supply_and_install"`). The value was treated as `null`
              (non-ESM). See the **ESM esmStatus v2 → v3 migration** note in the
              API description for corrective action.
            - `ACTUAL_RATE_INFORMATIONAL_ONLY` — `correctionType` is `"scaffold_split"`;
              `actualVatRate` reflects a blended hire+erection rate and must not be
              used to compute a correction amount. Always check `correctionType` before
              acting on `actualVatRate`.
        message:
          type: string
          description: Human-readable explanation of the problem and how to fix it.

    RuleEntry:
      type: object
      required: [label, noticeRef, explanation]
      description: Human-readable rule entry from the VAT rule registry.
      properties:
        label:
          type: string
          description: Short human-readable label for the rule.
          example: "431NB qualifying service — zero-rated"
        noticeRef:
          type: string
          description: HMRC Notice / legislative reference. Empty string if none.
          example: "Notice 708 s3.1"
        explanation:
          type: string
          description: One-sentence explanation of why this rule fired.

    CheckLineItemResponse:
      description: |
        Successful response from `POST /api/v1/check-line-item`.
        Machine clients may branch on `data.outcome.claimRoute` and `data.firedRuleId`.
        Both fields are stable within /api/v1/ — they will not change without a version bump.
      allOf:
        - $ref: "#/components/schemas/OkEnvelope"
        - type: object
          required: [data]
          properties:
            data:
              type: object
              required: [outcome, reclaimAmount, firedRuleId]
              properties:
                outcome:
                  $ref: "#/components/schemas/VatOutcome"
                reclaimAmount:
                  type: [string, "null"]
                  description: |
                    Estimated reclaimable amount as a decimal string, or `null` when no
                    amount can be confirmed. `null` is returned when `invalidRateBand` is
                    `true` and no other override (e.g. `correctionType: scaffold_split`)
                    supplies a definite figure.
                  example: "0.00"
                firedRuleId:
                  type: string
                  description: |
                    Stable opaque identifier of the rule that produced this outcome.
                    The same rule always returns the same value across requests and
                    deployments, but the format is not meaningful externally — do not
                    parse or pattern-match it. Branch on equality only.
                  example: "rule_a8f3c2d1e4b7f209"
                advisoryRuleIds:
                  type: array
                  items:
                    type: string
                  description: |
                    Opaque identifiers for advisory conditions that apply to this item
                    but do not override the primary `firedRuleId` routing outcome.
                    Currently emitted when the effective VAT rate falls outside the
                    valid UK bands (0%, 5%, 20%). Absent when no advisory conditions apply.
                invalidRateBand:
                  type: boolean
                  description: |
                    Present and `true` when the effective VAT rate on this line does not fall
                    within a standard UK VAT band (0%, 5%, 20%). Absent when not applicable.

                    When this flag is present:
                    - `remedy` is typically `"supplier_correction"` — an invalid rate is
                      usually a supplier invoicing error. Exception: when `correctionType:
                      scaffold_split` is also present, `remedy` is `"split_required"` because
                      the line must be split before any correction can be issued.
                    - `reclaimAmount` is `null` unless another condition overrides it (e.g.
                      `correctionType: scaffold_split` sets `reclaimAmount` to `"0"`). When
                      `null`, the correct amount cannot be determined until the supplier
                      reissues at the right rate.
                    - `actualVatRate` reflects the real computed rate (e.g. `12.5`), not
                      the nearest valid band — this shows the actual anomaly to agents.
                    - `expectedVatRate` is the rate HMRC expects for this item type.
                    - `invalidRateBandNote` contains a plain-English explanation for agents.
                invalidRateBandNote:
                  type: string
                  description: |
                    Human-readable explanation of the invalid rate band condition. Present
                    only when `invalidRateBand` is `true`. States the charged rate, confirms
                    it is not a valid UK VAT band, and instructs agents to request a corrected
                    invoice before submitting any reclaim.
                correctionType:
                  type: string
                  enum: [scaffold_split]
                  description: |
                    Present when the line cannot be corrected or reclaimed as a single
                    charge without first being split. Known values:

                    - `scaffold_split` — the line mixes scaffolding hire (standard-rated
                      at 20%) with erection/dismantling (potentially zero-rated on a
                      431NB project). HMRC distinguishes these two elements; a combined
                      charge cannot be issued as a single supplier correction. When this
                      field is present, `reclaimAmount` is always `"0"` — **do not treat
                      this item as a confirmed correction or approved claim until the line
                      has been split by the supplier and re-submitted**.

                    Absent when no split condition applies.
                splitHint:
                  type: object
                  description: |
                    Advisory hint present when a permanently-blocked line
                    (R_BLOCKED_*) also contains a qualifying construction item
                    alongside a conjunction token ("and" / "with" / comma).

                    When present, ask the supplier to reissue two separate
                    invoice lines so the qualifying portion named in
                    `qualifyingTerm` can be assessed independently.

                    `claimRoute` remains `not_reclaimable` — this field is
                    purely advisory with no routing consequence. `reclaimAmount`
                    is still `"0"` and `remedy` is still `"none"`.

                    Absent when the primary outcome is not R_BLOCKED_*, when no
                    conjunction-separated qualifying construction noun is found,
                    or when the conjunction only connects service verbs (fitting,
                    installation, supply, erection, etc.).
                  required: [detected, qualifyingTerm, message]
                  additionalProperties: false
                  properties:
                    detected:
                      type: boolean
                      enum: [true]
                      description: Always `true` when the object is present.
                    qualifyingTerm:
                      type: string
                      description: |
                        The qualifying construction noun detected in the line
                        (e.g. `"kitchen units"`, `"drainage"`, `"groundworks"`).
                        Use this to identify which element of the combined line
                        may be reclaimable if invoiced separately.
                    message:
                      type: string
                      description: |
                        Human-readable call-to-action for agents. Names the qualifying
                        term and instructs the agent to ask the supplier to reissue the
                        invoice as two separate lines — one for the qualifying item and
                        one for the blocked element — so the qualifying portion can be
                        assessed independently for VAT reclaim.

                        Example: `"This line appears to mix a qualifying construction
                        item (\"kitchen units\") with a permanently blocked element.
                        Ask the supplier to reissue the invoice as two separate lines —
                        one for \"kitchen units\" and one for the blocked element — so
                        the qualifying portion can be assessed independently for VAT
                        reclaim."`
                provisional:
                  type: boolean
                  description: |
                    Present and `true` when `outcome.reviewStatus` is `"pending"` — the
                    routing outcome is an **unconfirmed estimate** that has not yet been
                    reviewed or approved by a human in the VATBuild web app.

                    When this field is present, agents **must not** treat `reclaimAmount`
                    or `claimRoute` as confirmed monetary outcomes. Use the VATBuild web
                    app to review and approve pending items before acting on them.

                    Absent when the outcome is confirmed (`reviewStatus: "approved"` or
                    `"no_action"`).
                provisionalNote:
                  type: string
                  description: |
                    Human-readable explanation of the provisional state. Present when
                    `provisional` is `true`. Provides actionable guidance for agents.
                remedy:
                  type: string
                  enum: [hmrc_claim, supplier_correction, none, split_required]
                  description: |
                    Agent-facing action to take for this line item. A concise four-value
                    alternative to parsing `claimRoute` strings. Always present.

                    - `hmrc_claim` — file a VAT431NB/431C HMRC form to reclaim VAT paid
                    - `supplier_correction` — request a corrected invoice from the supplier
                    - `split_required` — the line mixes scaffolding hire and erection; the
                      supplier must re-issue as two separate lines before any correction or
                      claim can proceed. **Do not propose or calculate a split allocation** —
                      only the supplier knows the correct hire vs erection amounts. Instruct
                      the claimant to request an amended invoice with the two elements billed
                      separately. Set when `correctionType: "scaffold_split"` is present.
                    - `none` — no reclaim action needed (zero_at_source, not_reclaimable, pending, etc.)

                    This field does not replace `claimRoute` — it is an additive convenience.
                expectedVatRate:
                  type: [integer, "null"]
                  enum: [0, 5, 20, null]
                  description: |
                    The VAT rate (%) that HMRC expects for this item — 0, 5, or 20.

                    For `supplier_correction` items this is the rate the supplier *should*
                    have charged (e.g. 0 for a zero-rated construction service on a 431NB
                    project charged at 20%). For `hmrc_refund` items the supplier correctly
                    charged the expected rate (typically 20%).

                    Also set for `not_reclaimable` and `input_tax_blocked` items — the
                    supplier charged the correct rate even though no reclaim is possible
                    (e.g. architect fees correctly charged at 20%, or carpets at 20%).
                    This lets agents confirm no overcharge exists before closing the item.

                    `null` when the item is not eligible for a meaningful expected rate
                    (pending, pending_complex_answer, etc.).

                    Together with `actualVatRate`, an agent can independently verify the
                    correction amount without parsing text explanations.
                actualVatRate:
                  type: [number, "null"]
                  description: |
                    The effective VAT rate (%) actually charged on this line — derived from
                    `vatCharged ÷ netAmount`.

                    **Normal case** (valid rate band): snapped to the nearest UK VAT band
                    (0, 5, or 20) — e.g. 19.8% → 20, 4.9% → 5.

                    **Invalid rate band** (`invalidRateBand: true`): the raw computed rate
                    rounded to one decimal place (e.g. `12.5`). Snapping is suppressed so
                    agents see the actual anomaly rather than a misleading valid-band value.

                    `null` when `netAmount` is zero or missing.

                    **Always check `correctionType` before acting on this value.**
                    When `correctionType: "scaffold_split"` is present, this reflects a blended
                    hire+erection rate and must not be used to compute a correction amount.
                supplierCorrectionAmount:
                  type: string
                  description: |
                    The amount (decimal string) the supplier must refund as a credit note
                    for a 431C conversion service overcharged at 20% (instead of the correct 5%).

                    Only present when `outcome.claimRoute === "supplier_correction"` AND the
                    project is `self_build_431c`. Equals `reclaimAmount` — preserved as a named
                    field so agents can unambiguously identify Step 1 of the two-step 431C outcome.

                    Example: £2,000 net charged at 20% → supplier refunds £300
                    (£400 charged − £100 correct 5% VAT) → `supplierCorrectionAmount: "300.00"`.

                    Absent for all other routes and claim types.
                  example: "300.00"
                hmrcClaimAmountAfterCorrection:
                  type: string
                  description: |
                    The VAT amount (decimal string) the claimant can reclaim via the HMRC VAT431C
                    form AFTER obtaining the supplier credit note.

                    Only present alongside `supplierCorrectionAmount`. This is the 5% VAT the
                    supplier correctly charges on the corrected invoice — the claimant submits
                    this on the VAT431C form as Step 2 of the two-step 431C outcome.

                    Example: £2,000 net → corrected VAT at 5% = £100
                    → `hmrcClaimAmountAfterCorrection: "100.00"`.

                    The total financial benefit is `supplierCorrectionAmount` +
                    `hmrcClaimAmountAfterCorrection` (e.g. £300 + £100 = £400, recovering all
                    VAT incorrectly charged at 20%).

                    Absent for all other routes and claim types.
                  example: "100.00"
                dualOutcomeNote:
                  type: string
                  description: |
                    Explanatory note for 431C items with a two-step correction+claim outcome.
                    Present alongside `supplierCorrectionAmount`. Describes Step 1 (supplier
                    credit note for the overcharge) and Step 2 (HMRC 431C claim form for the
                    corrected VAT).

                    Use the named fields (`supplierCorrectionAmount`,
                    `hmrcClaimAmountAfterCorrection`) when communicating the full two-step
                    outcome to the user rather than relying solely on `reclaimAmount`.
                vatPaidAtCorrectRate:
                  type: string
                  description: |
                    The VAT amount (decimal string) that was correctly paid at the reduced rate
                    and therefore stays with the supplier — it is NOT reclaimable by the claimant.

                    Only present when `outcome.claimRoute === "reduced_at_source"` and
                    `remedy === "none"` — i.e. the supplier charged the correct 5% reduced rate
                    on a `self_build_reduced` project (empty dwelling ≥2 years). In this case
                    `reclaimAmount` is always `"0"` and `vatPaidAtCorrectRate` preserves the
                    factual VAT figure for information.

                    Example: a £2,000 net line charged at 5% → `vatPaidAtCorrectRate: "100.00"`,
                    `reclaimAmount: "0"`.

                    Absent for all other claim routes.
                  example: "100.00"
                requiresClaimantAttestation:
                  type: boolean
                  enum: [true]
                  description: |
                    Present and `true` when `outcome.claimRoute === "pending_complex_answer"`.
                    Signals that the question in `question.text` must be answered by the
                    claimant directly — an AI agent must not infer the answer from the invoice.
                    Absent on all other outcomes.
                question:
                  type: object
                  description: |
                    The follow-up question the claimant must answer before a final outcome can
                    be given. Only present when `requiresClaimantAttestation` is `true`.
                  required: [id, text, allowedAnswers]
                  additionalProperties: false
                  properties:
                    id:
                      type: string
                      description: |
                        Stable identifier for the question type (e.g. `"esm_install"`,
                        `"fitted_furniture"`). Pass one of these as `confirmedAnswer` on the
                        next call, or supply as `vatComplexTypeHint` in `answer_vat_complex_question`.
                    text:
                      type: string
                      description: Plain-English question text to present to the claimant verbatim.
                    allowedAnswers:
                      type: array
                      items:
                        type: string
                      description: |
                        Allowed answer values. Pass exactly one of these as `item.confirmedAnswer`
                        on a subsequent `check_line_item` call to resolve the classification.
                agentInstruction:
                  type: string
                  description: |
                    Instruction for AI agents. Present when `requiresClaimantAttestation` is
                    `true`. Default value: "Do not infer this answer from the invoice. Ask the
                    claimant to confirm it directly."

                    Some question types (e.g. `av_installation`) override this with a more
                    permissive instruction that allows the agent to self-resolve clear-cut
                    descriptions from the invoice text without escalating to the claimant.
                    **Read this field on every response — do not assume the default.**

                    Agents must not pre-fill or infer the `confirmedAnswer` value from invoice
                    text alone unless `agentInstruction` explicitly permits it; claimant
                    confirmation is required because the contractor description may be
                    incomplete or inaccurate.
                rulesVersion:
                  type: string
                  description: |
                    Semver version string of the VATBuild classification engine at the time of
                    this response (e.g. `"1.5.0"`). Always present on every successful response.

                    Callers may cache this value and flag any stored classification results for
                    re-evaluation when the version changes, since rule updates may alter the
                    outcome for previously-classified items.

                    Changes independently of `contractVersion` — a rule-logic change that
                    leaves the response shape identical bumps `rulesVersion` but not
                    `contractVersion`.
                  example: "1.5.0"
                contractVersion:
                  type: string
                  description: |
                    Date string (`YYYY-MM-DD`) of the most recent change to the MCP tool
                    response shape, allowed input values, or question registry
                    (e.g. `"2026-07-31"`). Always present on every successful response.

                    Use this signal independently of `rulesVersion`:
                    - When `rulesVersion` changes: re-run any cached classification results.
                    - When `contractVersion` changes: update your parsing logic — new response
                      fields, new allowed answer values, or new question types may have been
                      added.

                    This value does NOT change for pure rule-logic updates that leave the
                    response shape identical.
                  example: "2026-07-31"
                rule:
                  $ref: "#/components/schemas/RuleEntry"
            warnings:
              type: array
              items:
                $ref: "#/components/schemas/CheckLineItemWarning"
              description: |
                Non-fatal warnings raised when the request contained stale or deprecated
                input values that were accepted but may have caused misclassification.
                Absent (omitted) when no warnings apply.

                Machine clients should check for this field on every `ok: true` response
                and surface or log any entries so the extraction pipeline can be corrected.

                Known warning codes:
                - `ESM_STATUS_V2_VALUE` — stale v2 `esmStatus` value detected; treated as
                  `null`. See the **ESM esmStatus v2 → v3 migration** note in the API
                  description for corrective action.
                - `ACTUAL_RATE_INFORMATIONAL_ONLY` — `correctionType` is `"scaffold_split"`;
                  `actualVatRate` is a blended hire+erection rate. Do not use it to compute
                  a correction amount.

    ProjectSummary:
      type: object
      required: [projectId, name, journeyStatus, journeyStage, profileConfirmed, eligibleReclaim, netSpend, readinessScore, createdAt]
      properties:
        projectId:
          type: string
          format: uuid
        name:
          type: string
        claimantRoute:
          type: [string, "null"]
          description: VAT reclaim route for this project.
        journeyStatus:
          type: string
          description: Raw DB enum value for the project workflow state (active, complete, archived).
        journeyStage:
          type: string
          enum: [profile_setup, invoice_review, complete, archived]
          description: >
            Computed four-value journey stage. Splits the raw "active" DB status into
            "profile_setup" (profile not yet confirmed) and "invoice_review" (profile confirmed,
            invoices can be uploaded and reviewed). Use this field instead of deriving the stage
            from journeyStatus + profileConfirmed yourself.
        profileConfirmed:
          type: boolean
          description: Whether the project profile has been confirmed by the user.
        eligibleReclaim:
          type: [string, "null"]
          description: Total eligible VAT reclaim amount as a decimal string.
        netSpend:
          type: [string, "null"]
          description: Total net spend across all invoices as a decimal string.
        readinessScore:
          type: [number, "null"]
          description: Percentage readiness score (0–100).
        createdAt:
          type: [string, "null"]
          format: date-time

    LineItem:
      type: object
      required: [lineItemId, reviewStatus, crossItemContextRequired]
      properties:
        lineItemId:
          type: string
          format: uuid
        description:
          type: [string, "null"]
          description: Invoice line text.
        category:
          type: [string, "null"]
          description: Taxonomy identifier name matched by the AI.
        vatCharged:
          type: [string, "null"]
          description: VAT charged on this line as a decimal string.
        reclaimAmount:
          type: [string, "null"]
          description: Reclaimable amount for this line as a decimal string.
        claimable:
          type: [boolean, "null"]
          description: Whether this item is claimable.
        reviewStatus:
          type: string
          enum: [pending, approved, rejected, no_action]
          description: >
            Current review status. `no_action` means the line needs no reviewer action.
            `rejected` is set by a reviewer and may appear on stored items returned by
            list_line_items; it is never returned by check_line_item.
        vatComplexType:
          type: [string, "null"]
          description: Complex-VAT type tag (e.g. `esm_install`, `scaffolding`).
        crossItemContextRequired:
          type: boolean
          description: |
            True when `vatComplexType` is `esm_install` or `supply_and_install`,
            meaning invoice-level context is needed for accurate classification.
        correctionType:
          type: string
          enum: [scaffold_split]
          description: |
            Present when the line cannot be corrected or reclaimed as a single charge
            without first being split. Currently one value:

            - `scaffold_split` — the line mixes scaffolding hire (standard-rated at 20%)
              with erection/dismantling (potentially zero-rated on a 431NB project). HMRC
              distinguishes these two elements; a combined charge cannot be issued as a
              single supplier correction. When this field is present, `reclaimAmount` is
              always `"0"` — **do not treat this item as a confirmed correction or approved
              claim until the line has been split by the supplier and re-submitted**.

            Absent when no split condition applies.
        provisional:
          type: boolean
          description: |
            Present and `true` when `reviewStatus` is `"pending"` — the routing outcome is
            an **unconfirmed estimate** that has not yet been reviewed or approved by a human
            in the VATBuild web app.

            When this field is present, agents **must not** treat `reclaimAmount` or
            `claimRoute` as confirmed monetary outcomes. Use the VATBuild web app to review
            and approve pending items before acting on them.

            Absent when the outcome is confirmed (`reviewStatus: "approved"` or `"no_action"`).
        remedy:
          type: string
          enum: [hmrc_claim, supplier_correction, none, split_required]
          description: |
            Agent-facing action to take for this line item. A concise four-value
            alternative to parsing `claimRoute` strings. Always present.

            - `hmrc_claim` — file a VAT431NB/431C HMRC form
            - `supplier_correction` — request a corrected invoice from the supplier
            - `split_required` — the supplier must re-issue as two separate lines (hire vs
              erection) before any correction or claim can proceed. Do not propose or
              calculate a split allocation — only the supplier knows the correct amounts
              (`correctionType: scaffold_split`)
            - `none` — no reclaim action needed
        expectedVatRate:
          type: [integer, "null"]
          enum: [0, 5, 20, null]
          description: |
            The VAT rate (%) that HMRC expects for this item — 0, 5, or 20, or `null`
            when not applicable (pending, pending_complex_answer, etc.).
            Also set for `not_reclaimable` items where the supplier charged the correct
            rate (e.g. architect fees or carpets at 20%).
        actualVatRate:
          type: [integer, "null"]
          enum: [0, 5, 20, null]
          description: |
            The effective VAT rate (%) actually charged — derived from
            `vatCharged ÷ netAmount`, snapped to the nearest UK VAT band.
            `null` when `netAmount` is zero or missing.

    GenerateReportResponse:
      description: |
        Successful response from the `generate_report` MCP tool.
        Returns a structured claim schedule split into the two recovery streams:
        HMRC claim (submitted via 431NB/431C) and supplier corrections (supplier must
        issue a corrected invoice first).
      allOf:
        - $ref: "#/components/schemas/OkEnvelope"
        - type: object
          required: [data]
          properties:
            data:
              type: object
              required: [summary, hmrcClaim, supplierCorrections, readinessAssessment]
              properties:
                summary:
                  type: object
                  required:
                    [totalHmrcClaimAmount, totalSupplierCorrectionAmount, totalReclaimableAmount,
                     itemCount, pendingItemCount, unresolvedItemCount, reportGeneratedAt]
                  properties:
                    totalHmrcClaimAmount:
                      type: string
                      description: Total reclaimable directly from HMRC as a decimal GBP string.
                    totalSupplierCorrectionAmount:
                      type: string
                      description: Total overcharge requiring supplier correction as a decimal GBP string.
                    totalReclaimableAmount:
                      type: string
                      description: Sum of both streams as a decimal GBP string.
                    itemCount:
                      type: integer
                      description: Number of approved reclaimable line items.
                    pendingItemCount:
                      type: integer
                      description: Number of items still awaiting review.
                    unresolvedItemCount:
                      type: integer
                      description: Number of items with no routing outcome yet.
                    reportGeneratedAt:
                      type: string
                      format: date-time
                      description: ISO 8601 timestamp when the report was generated.
                hmrcClaim:
                  type: object
                  required: [amount, invoiceCount, lineItemCount, readyToSubmit]
                  properties:
                    amount:
                      type: string
                      description: Total HMRC claim amount as a decimal GBP string.
                    invoiceCount:
                      type: integer
                      description: Number of invoices contributing to the HMRC claim.
                    lineItemCount:
                      type: integer
                      description: Number of line items in the HMRC claim.
                    readyToSubmit:
                      type: boolean
                      description: |
                        `true` when the project profile is confirmed, all items are
                        resolved, and there is at least one reclaimable item.
                supplierCorrections:
                  type: array
                  description: |
                    One entry per supplier with correction items. Empty when there are
                    no supplier correction items in the project.
                  items:
                    type: object
                    required: [supplierName, supplierVatNumber, correctionAmount, invoiceCount, lineItemIds]
                    properties:
                      supplierName:
                        type: [string, "null"]
                      supplierVatNumber:
                        type: [string, "null"]
                      correctionAmount:
                        type: string
                        description: Total amount the supplier must correct as a decimal GBP string.
                      invoiceCount:
                        type: integer
                      lineItemIds:
                        type: array
                        items:
                          type: string
                          format: uuid
                readinessAssessment:
                  type: object
                  required: [score, blockers]
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Readiness score 0–100. Higher is better.
                    blockers:
                      type: array
                      description: |
                        Plain-English list of issues preventing HMRC submission.
                        Empty when the project is ready to submit.
                      items:
                        type: string
                lineItems:
                  type: array
                  description: |
                    Full per-line-item schedule. Only present when `includeLineItems: true`
                    was passed. Each item matches the `list_line_items` per-item shape.
                  items:
                    $ref: "#/components/schemas/LineItem"

    GetInvoiceResponse:
      description: |
        Successful response from the `get_invoice` MCP tool.
        Returns the full extracted document header plus all extracted line items.
      allOf:
        - $ref: "#/components/schemas/OkEnvelope"
        - type: object
          required: [data]
          properties:
            data:
              type: object
              required: [document, lineItems]
              properties:
                document:
                  type: object
                  required: [documentId, filename, extractionStatus, analyzed]
                  properties:
                    documentId:
                      type: string
                      format: uuid
                    filename:
                      type: string
                      description: Original uploaded filename.
                    supplierName:
                      type: [string, "null"]
                      description: Extracted supplier name.
                    supplierVatNumber:
                      type: [string, "null"]
                      description: Supplier VAT registration number.
                    invoiceRef:
                      type: [string, "null"]
                      description: Invoice reference / number from the header.
                    invoiceDate:
                      type: [string, "null"]
                      description: Invoice date string from the header.
                    totalNet:
                      type: [string, "null"]
                      description: |
                        Sum of all line item `netAmount` values as a decimal string.
                        `null` when no line items exist.
                    totalVat:
                      type: [string, "null"]
                      description: |
                        Sum of all line item `vatCharged` values as a decimal string.
                        `null` when no line items exist.
                    extractionStatus:
                      type: [string, "null"]
                      description: |
                        Current extraction state. Always `"extracted"` in a successful
                        response (the tool returns an error for other states).
                    invoiceValid:
                      type: [boolean, "null"]
                      description: HMRC validity flag. `false` when a validation problem was detected.
                    invalidReason:
                      type: [string, "null"]
                      description: |
                        Machine-readable reason code for an invalid invoice.
                        `null` when `invoiceValid` is `true` or validity has not been assessed.
                    analyzed:
                      type: boolean
                      description: Whether AI analysis has run on this document.
                lineItems:
                  type: array
                  description: |
                    All extracted line items for this invoice. Matches the `list_line_items`
                    per-item shape exactly — same fields, same `remedy`/rate additions —
                    so agents can use the same parsing logic for both tools.
                  items:
                    $ref: "#/components/schemas/LineItem"

    WorkPackageLite:
      type: object
      description: |
        Lite (discovery-only) view of the work-package library.
        Returned for anonymous callers or users without a paid plan.
      required: [libraryType, totalPackages, categories, note]
      properties:
        libraryType:
          type: string
          enum: [new_build, conversion, renovation]
        totalPackages:
          type: integer
        categories:
          type: array
          items:
            type: object
            required: [heading, count]
            properties:
              heading:
                type: string
              count:
                type: integer
        note:
          type: string
          example: "Full package detail (VAT treatment, claim route, HMRC guidance) requires a paid plan."

    WorkPackageFull:
      type: object
      description: |
        Full work-package entry. Returned only for users with a paid plan and active subscription.
      required: [ref, categoryHeading, name]
      properties:
        ref:
          type: string
          description: Unique package reference code.
        categoryHeading:
          type: string
        name:
          type: string
        scope:
          type: [string, "null"]
        alwaysPresent:
          type: [boolean, "null"]
        vatNote:
          type: [string, "null"]
        category:
          type: [string, "null"]
        procurementCategory:
          type: [string, "null"]
        vatTreatment:
          type: [string, "null"]
        claimRoute:
          type: [string, "null"]
        isSupplyAndInstall:
          type: [boolean, "null"]
        isEsm:
          type: [boolean, "null"]
        isScaffolding:
          type: [boolean, "null"]
        isPotentialEsm:
          type: [boolean, "null"]
        isCarpetFlooring:
          type: [boolean, "null"]
        isSoftLandscaping:
          type: [boolean, "null"]
        isNonQualifyingFittedFurniture:
          type: [boolean, "null"]
        isNonQualifyingAppliance:
          type: [boolean, "null"]
        isProfessionalFees:
          type: [boolean, "null"]
        isDryPlantHire:
          type: [boolean, "null"]
        isPlanningConditional:
          type: [boolean, "null"]
        isGarageOutbuilding:
          type: [boolean, "null"]
        vatComplex:
          type: [boolean, "null"]
        splitRole:
          type: [string, "null"]

    ClaimantRoute:
      type: object
      description: Claimant-route configuration entry.
      required: [key, label, summaryTabLabel, isHmrcClaimScheme, allowedClaimRouteOutcomes, checklistKeys]
      properties:
        key:
          type: string
          description: Unique route identifier (e.g. `self_build_431nb`).
        label:
          type: string
          description: Human-readable route label.
        summaryTabLabel:
          type: string
          description: Label used in the project summary tab.
        isHmrcClaimScheme:
          type: boolean
          description: Whether this route uses the HMRC 431 form scheme.
        allowedClaimRouteOutcomes:
          type: array
          items:
            type: string
          description: Claim-route outcome values permitted for this claimant route.
        checklistKeys:
          type: array
          items:
            type: string
          description: Pre-claim checklist item keys for this route.

    # ── Named error schemas ──────────────────────────────────────────────────
    # Each uses allOf to compose with ErrorEnvelope (shared ok/error shape)
    # and then narrows the error.code to a specific enum value.

    ErrorInvalidInput:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [INVALID_INPUT]
                message:
                  type: string
                  description: Semicolon-separated list of field-level Zod validation errors.
                  example: "item.netAmount: Expected string, received number"

    ErrorCheckLineItemBadRequest:
      description: |
        Error shape for `POST /api/v1/check-line-item` 400 responses.
        Narrows `error.code` to the three codes this endpoint can return.
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [INVALID_INPUT, MISSING_CLAIMANT_ROUTE, ROUTE_NOT_SUPPORTED]
                  description: |
                    Machine-readable error code. One of:
                    - `INVALID_INPUT` — Zod schema validation failed; `message` is a
                      semicolon-separated list of field-level errors.
                    - `MISSING_CLAIMANT_ROUTE` — `context.claimantRoute` was absent or null.
                      Pass the project's claimant route (e.g. `"self_build_431nb"`).
                    - `ROUTE_NOT_SUPPORTED` — `context.claimantRoute` is a recognised value
                      but is not yet accepted as input (e.g. `"developer"`, `"contractor"`,
                      `"reverse_charge_cis"`). See `claimantRoute` description for details.
                message:
                  type: string
                  description: Human-readable detail or semicolon-separated Zod field errors.
                  example: "context.claimantRoute is required."

    ErrorServiceUnavailable:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [SERVICE_UNAVAILABLE]
                message:
                  type: string
                  example: "service_initialising"

    ErrorRateLimited:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [RATE_LIMITED]
                message:
                  type: string
                  example: "Too many requests. Please retry after the Retry-After interval."

    ErrorCheckLineItemRateLimited:
      description: |
        Rate-limit response for `POST /api/v1/check-line-item`.
        The free daily cap has been reached. `error.detail.upgradeUrl` links to the upgrade
        page; `error.detail.retryAfterSecs` indicates when the cap resets.
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              required: [code, message, detail]
              properties:
                code:
                  type: string
                  enum: [RATE_LIMITED]
                message:
                  type: string
                  example: "Daily classification limit reached. Upgrade to continue."
                detail:
                  type: object
                  required: [upgradeUrl, retryAfterSecs]
                  properties:
                    upgradeUrl:
                      type: string
                      format: uri
                      description: URL of the upgrade/pricing page.
                      example: "https://vatbuild.com/pricing"
                    retryAfterSecs:
                      type: integer
                      description: Seconds until the daily cap resets.
                      example: 3600

    ErrorQuotaExceeded:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          required: [error]
          properties:
            error:
              type: object
              required: [limit, used]
              properties:
                code:
                  type: string
                  enum: [QUOTA_EXCEEDED]
                message:
                  type: string
                  example: "Daily extraction quota of 200 reached for this organisation. Resets at UTC midnight."
                limit:
                  type: integer
                  description: Effective daily quota limit for this organisation.
                  example: 200
                used:
                  type: integer
                  description: Number of extractions already used today.
                  example: 200

    ErrorAssessmentNotConfirmed:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          required: [error]
          properties:
            error:
              type: object
              required: [assessmentStatus]
              properties:
                code:
                  type: string
                  enum: [ASSESSMENT_NOT_CONFIRMED]
                message:
                  type: string
                  example: "Invoice upload is not permitted until the project assessment is confirmed."
                assessmentStatus:
                  type: string
                  description: Current assessment status.
                  example: questionnaire_required

    ErrorAssessmentNotReady:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          required: [error]
          properties:
            error:
              type: object
              required: [assessmentStatus]
              properties:
                code:
                  type: string
                  enum: [ASSESSMENT_NOT_READY]
                assessmentStatus:
                  type: string

    ErrorLineItemsNotReviewed:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          required: [error]
          properties:
            error:
              type: object
              required: [pendingCount]
              properties:
                code:
                  type: string
                  enum: [LINE_ITEMS_NOT_REVIEWED]
                message:
                  type: string
                  example: "3 line item(s) still require review before locking."
                pendingCount:
                  type: integer
                  description: Number of line items with reviewStatus = pending.
                  example: 3

    ErrorAlreadyLocked:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          required: [error]
          properties:
            error:
              type: object
              required: [lockedAt, lockedBy]
              properties:
                code:
                  type: string
                  enum: [ALREADY_LOCKED]
                message:
                  type: string
                  example: "Assessment is already locked."
                lockedAt:
                  type: [string, "null"]
                  format: date-time
                lockedBy:
                  type: [string, "null"]
                  description: User ID or API key ID of the principal that locked the assessment.

    ErrorNotFound:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [NOT_FOUND]
                message:
                  type: string
                  example: "Project not found"

    ErrorAccessDenied:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [ACCESS_DENIED]
                message:
                  type: string
                  example: "Access denied"

    ErrorNoOrg:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [NO_ORG]
                message:
                  type: string
                  example: "No active organisation found for this account. Complete registration first."

    ErrorInternal:
      allOf:
        - $ref: "#/components/schemas/ErrorEnvelope"
        - type: object
          properties:
            error:
              type: object
              properties:
                code:
                  type: string
                  enum: [INTERNAL_ERROR]
                message:
                  type: string
                  example: "An internal error occurred"

  # ── Reusable responses ──────────────────────────────────────────────────────

  responses:
    InvalidInput:
      description: Request validation failed. Body contains a list of Zod field errors.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorInvalidInput"

    CheckLineItemBadRequest:
      description: |
        Request validation failed for `POST /api/v1/check-line-item`.
        `error.code` distinguishes three cases: Zod field-level errors (`INVALID_INPUT`),
        missing `context.claimantRoute` (`MISSING_CLAIMANT_ROUTE`), and an unsupported
        route value (`ROUTE_NOT_SUPPORTED`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorCheckLineItemBadRequest"

    RateLimited:
      description: Per-IP or per-key rate limit exceeded.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorRateLimited"

    CheckLineItemRateLimited:
      description: |
        Daily classification cap exceeded for `POST /api/v1/check-line-item`.
        `error.detail.upgradeUrl` links to the pricing page.
        `error.detail.retryAfterSecs` is the number of seconds until the cap resets.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorCheckLineItemRateLimited"

    NotFound:
      description: Resource not found or cross-tenant existence hiding.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorNotFound"

    AccessDenied:
      description: Authenticated but insufficient scope.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorAccessDenied"

    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorInternal"

# ── Paths ─────────────────────────────────────────────────────────────────────

paths:

  /api/v1/check-line-item:
    post:
      operationId: checkLineItem
      tags: [Classification]
      summary: Classify a single invoice line item (no auth, no persistence)
      description: |
        Stateless endpoint that classifies a single construction invoice line item
        against inline project-context inputs and returns the VAT routing outcome
        with a human-readable rule explanation.

        No authentication required. No data is persisted.
        Safe to call speculatively and in batch pipelines that manage their own persistence.

        Rate limited by `restComputeLimiter` (per IP).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [context, item]
              properties:
                context:
                  allOf:
                    - $ref: "#/components/schemas/InlineProjectContext"
                    - type: object
                      required: [claimantRoute]
                      description: |
                        `claimantRoute` is required for this endpoint. Omitting it returns
                        HTTP 400 with `error.code: "MISSING_CLAIMANT_ROUTE"`.
                item:
                  $ref: "#/components/schemas/CheckLineItemItem"
                verbose:
                  type: boolean
                  default: true
                  description: |
                    When `false`, the `rule` field (label, noticeRef, explanation) is omitted from
                    the response to reduce response size and token consumption.
                    Defaults to `true` (rule annotation always included) for backward compatibility.

                    Use `verbose: false` in batch pipelines where rule explanations are not needed
                    on every call — typically when the agent has already read the rule copy once
                    and only needs the monetary outcome (reclaimAmount, claimRoute, remedy).
            example:
              context:
                projectType: New build dwelling
                newDwelling: "yes"
                claimantRoute: self_build_431nb
              item:
                lineText: Structural brickwork and blockwork
                netAmount: "5000.00"
                vatCharged: "0.00"
                supplyType: labour
                identifierName: Structural works
      responses:
        "200":
          description: Classification result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckLineItemResponse"
        "400":
          $ref: "#/components/responses/CheckLineItemBadRequest"
        "429":
          $ref: "#/components/responses/CheckLineItemRateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          description: |
            Service temporarily unavailable — retry after the number of seconds in the `Retry-After` header.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorServiceUnavailable"

  /api/v1/projects:
    post:
      operationId: createProject
      tags: [Projects]
      summary: Create a project
      description: |
        Creates a new project for the authenticated user's organisation
        and derives a VAT treatment estimate synchronously.

        Optionally accepts a `document` object to enqueue a VAT extraction job immediately.
        When `document` is supplied the response includes `jobId` for polling via
        `GET /api/v1/jobs/:jobId`.

        **Scope required:** `projects:write`.
        A Bearer key missing this scope receives `403 ACCESS_DENIED`.

        Rate limited by `v1WriteLimiter`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/InlineProjectContext"
                - type: object
                  required: [name]
                  properties:
                    name:
                      type: string
                      minLength: 1
                      description: Human-readable project name.
                      example: Oak Lane New Build
                    document:
                      type: object
                      description: |
                        Optional document to enqueue for VAT extraction analysis.
                        When supplied the response includes `jobId`.
                        `filename` must contain only letters, digits, dots, hyphens,
                        and underscores — no path separators or traversal sequences.
                      required: [filename, originalName]
                      properties:
                        filename:
                          type: string
                          pattern: "^[A-Za-z0-9._-]+$"
                          description: Bare filename for the invoice file — letters, digits, dots, hyphens, and underscores only; no path separators or slashes.
                          example: invoice-001.pdf
                        originalName:
                          type: string
                          minLength: 1
                          description: Human-readable file name.
                          example: invoice-001.pdf
                        mimeType:
                          type: string
                          description: MIME type of the file.
                          example: application/pdf
            example:
              name: Oak Lane New Build
              projectType: New build dwelling
              newDwelling: "yes"
              claimantRoute: self_build_431nb
      responses:
        "201":
          description: Project created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [project, estimate]
                        properties:
                          project:
                            type: object
                            required: [id, name, orgId, status, createdAt]
                            properties:
                              id:
                                type: string
                                format: uuid
                              name:
                                type: string
                              orgId:
                                type: string
                                format: uuid
                              status:
                                type: [string, "null"]
                              createdAt:
                                type: [string, "null"]
                                format: date-time
                          estimate:
                            $ref: "#/components/schemas/ClassificationContext"
                          jobId:
                            type: string
                            format: uuid
                            description: |
                              Present only when a `document` was supplied.
                              Poll `GET /api/v1/jobs/:jobId` for extraction status.
        "400":
          description: Validation error or no active organisation.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ErrorInvalidInput"
                  - $ref: "#/components/schemas/ErrorNoOrg"
        "403":
          description: Insufficient scope (`projects:write` required for Bearer keys).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorAccessDenied"
        "429":
          description: Rate limited or daily extraction quota exceeded (when `document` supplied).
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ErrorRateLimited"
                  - $ref: "#/components/schemas/ErrorQuotaExceeded"
        "500":
          $ref: "#/components/responses/InternalError"

    get:
      operationId: listProjects
      tags: [Projects]
      summary: List all projects in the organisation
      description: |
        Returns all projects belonging to the authenticated user's organisation.
        No scope claim is required beyond authentication.
        Rate limited by `v1ActionLimiter`.

        Use `?outstandingActionsOnly=true` to filter to projects that have at least one
        VAT Actions badge with `status === "action"`, so agents can prioritise which
        projects still need attention without parsing the full list locally.
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: outstandingActionsOnly
          required: false
          schema:
            type: boolean
          description: |
            When `true`, only projects with at least one `vatActions` badge whose
            `status` is `"action"` are returned. Projects that are fully up to date
            (all badges `"done"` or `"not_started"`) are excluded.
        - in: query
          name: journeyStatus
          required: false
          schema:
            type: string
            enum: [profile_setup, invoice_review, complete, archived]
          description: |
            Filter to projects at a specific journey stage.

            | Value | Meaning |
            |---|---|
            | `profile_setup` | Profile not yet confirmed by the user. Project is still in the setup wizard. |
            | `invoice_review` | Profile confirmed; invoices can be uploaded and reviewed. |
            | `complete` | Project has been marked complete. |
            | `archived` | Project has been archived. |

            Combines with `outstandingActionsOnly` and `minReadinessScore` using AND logic.
        - in: query
          name: minReadinessScore
          required: false
          schema:
            type: integer
            minimum: 0
            maximum: 100
          description: |
            Exclude projects whose `readinessScore` is below this threshold.
            Projects with a `null` readiness score are also excluded when this
            filter is set. Useful for focusing on projects closest to HMRC
            submission readiness.
      responses:
        "200":
          description: List of project summaries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [projects]
                        properties:
                          projects:
                            type: array
                            items:
                              $ref: "#/components/schemas/ProjectSummary"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/projects/{id}/estimate:
    get:
      operationId: getProjectEstimate
      tags: [Projects]
      summary: Derive VAT treatment estimate for an existing project
      description: |
        Returns the derived `ClassificationContext` for an existing project using
        its stored profile fields.

        **Scope required:** `projects:read`.
        A Bearer key missing this scope receives `403 ACCESS_DENIED`.

        Cross-tenant access is silently hidden as `404 NOT_FOUND`.
        Rate limited by `v1ActionLimiter`.
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Project UUID.
      responses:
        "200":
          description: Derived VAT estimate.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [projectId, estimate]
                        properties:
                          projectId:
                            type: string
                            format: uuid
                          estimate:
                            $ref: "#/components/schemas/ClassificationContext"
        "403":
          $ref: "#/components/responses/AccessDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/projects/{id}/line-items:
    get:
      operationId: listLineItems
      tags: [Projects]
      summary: List classified invoice line items for a project
      description: |
        Returns a paginated list of classified invoice line items for a project.

        **Scope:** authentication only — no additional scope claim is enforced by the
        current implementation (mirrors the `list_line_items` MCP tool). Session
        principals hold all scopes implicitly; Bearer callers need no explicit scope claim
        for this read-only endpoint.

        Cross-tenant access is silently hidden as `404 NOT_FOUND`.
        Rate limited by `v1ActionLimiter`.
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Project UUID.
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, approved, all]
            default: all
          description: Filter by review status.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
          description: Page size.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Pagination offset.
      responses:
        "200":
          description: Paginated line items.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [items, total, offset, limit]
                        properties:
                          items:
                            type: array
                            items:
                              $ref: "#/components/schemas/LineItem"
                          total:
                            type: integer
                            description: Total number of items matching the filter.
                          offset:
                            type: integer
                          limit:
                            type: integer
        "400":
          $ref: "#/components/responses/InvalidInput"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/invoices:
    post:
      operationId: submitInvoice
      tags: [Invoices]
      summary: Submit an invoice for VAT extraction analysis
      description: |
        Enqueues an invoice file for async VAT extraction and classification.
        Returns a `jobId` to poll via `GET /api/v1/jobs/:jobId`.

        **Phase gate:** the project's assessment must be in `assessment_ready` status.
        Any other status returns `409 ASSESSMENT_NOT_CONFIRMED`.

        **Daily quota:** each organisation has a per-day extraction limit
        (free: 25, paid: 200). Exceeding it returns `429 QUOTA_EXCEEDED`.

        `filename` must match `^[A-Za-z0-9._-]+$` (no path separators or traversal sequences).

        **Scope required:** `invoices:write`.
        Cross-tenant access is silently hidden as `404 NOT_FOUND`.
        Rate limited by `v1WriteLimiter`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projectId, filename, originalName]
              properties:
                projectId:
                  type: string
                  format: uuid
                  description: Target project UUID.
                filename:
                  type: string
                  pattern: "^[A-Za-z0-9._-]+$"
                  minLength: 1
                  description: Bare filename for the invoice file — letters, digits, dots, hyphens, and underscores only; no path separators or slashes.
                  example: invoice-001.pdf
                originalName:
                  type: string
                  minLength: 1
                  description: Human-readable file name shown in the UI.
                  example: invoice-001.pdf
                mimeType:
                  type: string
                  description: MIME type of the file.
                  example: application/pdf
            example:
              projectId: 550e8400-e29b-41d4-a716-446655440000
              filename: invoice-001.pdf
              originalName: invoice-001.pdf
              mimeType: application/pdf
      responses:
        "201":
          description: Invoice accepted and extraction job enqueued.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [documentId, projectId, jobId]
                        properties:
                          documentId:
                            type: string
                            format: uuid
                            description: ID of the newly-created document record.
                          projectId:
                            type: string
                            format: uuid
                          jobId:
                            type: string
                            format: uuid
                            description: Poll this via `GET /api/v1/jobs/:jobId`.
        "400":
          $ref: "#/components/responses/InvalidInput"
        "403":
          $ref: "#/components/responses/AccessDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Phase-gate precondition not met — assessment not in `assessment_ready` status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorAssessmentNotConfirmed"
        "429":
          description: Rate limited or daily extraction quota exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ErrorRateLimited"
                  - $ref: "#/components/schemas/ErrorQuotaExceeded"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/invoices/lock:
    post:
      operationId: lockAssessment
      tags: [Invoices]
      summary: Lock the assessment once all line items are reviewed
      description: |
        Locks the project's assessment, preventing further invoice submissions.

        **Preconditions (checked in order):**
        1. Assessment must be in `assessment_ready` status → `409 ASSESSMENT_NOT_READY`
        2. No line items with `reviewStatus = pending` → `409 LINE_ITEMS_NOT_REVIEWED`
        3. Assessment must not already be locked → `409 ALREADY_LOCKED`

        The actor recorded in `lockedBy` is the API key ID for Bearer callers,
        or the user ID for session callers.

        **Scope required:** `invoices:write`.
        Cross-tenant access is silently hidden as `404 NOT_FOUND`.
        Rate limited by `v1ActionLimiter`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projectId]
              properties:
                projectId:
                  type: string
                  format: uuid
                  description: Project UUID to lock.
            example:
              projectId: 550e8400-e29b-41d4-a716-446655440000
      responses:
        "200":
          description: Assessment successfully locked.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [assessmentId, projectId, lockedAt, lockedBy]
                        properties:
                          assessmentId:
                            type: string
                            format: uuid
                          projectId:
                            type: string
                            format: uuid
                          lockedAt:
                            type: [string, "null"]
                            format: date-time
                          lockedBy:
                            type: [string, "null"]
                            description: User ID or API key ID of the locking principal.
        "400":
          $ref: "#/components/responses/InvalidInput"
        "403":
          $ref: "#/components/responses/AccessDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Precondition not met.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ErrorAssessmentNotReady"
                  - $ref: "#/components/schemas/ErrorLineItemsNotReviewed"
                  - $ref: "#/components/schemas/ErrorAlreadyLocked"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/jobs/{jobId}:
    get:
      operationId: getJob
      tags: [Jobs]
      summary: Poll an async extraction job
      description: |
        Returns the current status and progress of an async document extraction job.

        **Job statuses** (see `JobStatus` schema):
        - `queued` — waiting in the pg-boss queue.
        - `active` — running; `data.progress` is included when available.
        - `completed` — finished successfully; `data.result` is included.
        - `failed` — terminal failure; `data.error` is included.

        Cross-tenant access (job belongs to a project the caller cannot access) is
        silently hidden as `404 NOT_FOUND`.

        **Scope required:** `jobs:read`.
        Rate limited by `v1ActionLimiter`.
      security:
        - bearerAuth: []
      parameters:
        - name: jobId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Job UUID returned by `POST /api/v1/invoices` or `POST /api/v1/projects`.
      responses:
        "200":
          description: Job status and optional progress or result.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [jobId, status, projectId]
                        properties:
                          jobId:
                            type: string
                            format: uuid
                          status:
                            $ref: "#/components/schemas/JobStatus"
                          projectId:
                            type: string
                            format: uuid
                          progress:
                            type: object
                            description: Present only for `active` jobs (when available from the worker).
                            required: [batchId, completed, total, currentDocument]
                            properties:
                              batchId:
                                type: string
                                format: uuid
                              completed:
                                type: integer
                              total:
                                type: integer
                              currentDocument:
                                type: string
                          result:
                            description: |
                              Present only for `completed` jobs.
                              Shape is determined by the extraction worker output.
                          error:
                            type: object
                            description: Present only for `failed` jobs.
                            properties:
                              code:
                                type: string
                              message:
                                type: string
        "403":
          $ref: "#/components/responses/AccessDenied"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          description: |
            Job queue schema not yet initialised (server starting up). Retry shortly.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorServiceUnavailable"

  /api/v1/reference/routes:
    get:
      operationId: listClaimantRoutes
      tags: [Reference]
      summary: List all claimant-route configurations
      description: |
        Returns all claimant-route configs from the CLAIMANT_ROUTES registry.
        Fully public — no authentication required, no entitlement gate.
        Cached publicly for 1 hour (`Cache-Control: public, max-age=3600`).
        Rate limited by `publicScrapeLimiter`.
      security: []
      responses:
        "200":
          description: List of claimant-route configurations.
          headers:
            Cache-Control:
              schema:
                type: string
              example: "public, max-age=3600"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items:
                          $ref: "#/components/schemas/ClaimantRoute"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/reference/work-packages:
    get:
      operationId: listWorkPackages
      tags: [Reference]
      summary: List work-package library (lite or full depending on entitlement)
      description: |
        Returns the base work-package library. The response tier depends on
        the caller's plan entitlement:

        - **Not entitled** (anonymous, free plan, or no active subscription) →
          **Lite** response: category headings and counts only.
          `Cache-Control: public, max-age=3600` (CDN-cacheable).

        - **Entitled** (paid plan + active subscription) →
          **Full** response: complete package detail including VAT treatment,
          claim route, and HMRC guidance.
          `Cache-Control: private, max-age=300` (must not land in a shared cache).

        Authentication is optional. An anonymous caller always receives lite.
        A Bearer API key is accepted; entitlement is evaluated on the key-owner's plan.

        Rate limited by `publicScrapeLimiter`.

        **Note:** this endpoint returns **base constants only** — never org-specific
        library overrides (which would constitute a cross-tenant data leak).
      security: []
      parameters:
        - name: libraryType
          in: query
          required: true
          schema:
            type: string
            enum: [new_build, conversion, renovation]
          description: Which work-package library to return.
      responses:
        "200":
          description: Work-package library (lite or full depending on entitlement).
          headers:
            Cache-Control:
              schema:
                type: string
              examples:
                lite:
                  value: "public, max-age=3600"
                full:
                  value: "private, max-age=300"
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OkEnvelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        oneOf:
                          - $ref: "#/components/schemas/WorkPackageLite"
                          - type: array
                            items:
                              $ref: "#/components/schemas/WorkPackageFull"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
