openapi: 3.1.0
info:
  title: POSnavigator Provider Research Knowledge API
  version: 1.0.0
  description: |-
    Canonical machine-readable contract for the POSnavigator provider research
    knowledge store and proposal inbox. This document is the complete manual: an
    agent given only this URL and a valid API key can complete the whole workflow
    without any other documentation.

    # What this API is for

    POSnavigator stores research *knowledge* (Playbooks), *completed run reports*,
    *Lessons*, and *change proposals*. It never browses, scrapes, renders PDFs, runs
    cron, enqueues work, assigns agents, or decides which Bank is due. You do the
    research outside POSnavigator and report the result here.

    **You can never write live Bank, product, or fee data.** Every change you
    propose is stored for a human admin to review. Only the human approval path
    applies changes to live data. There is no agent-accessible write path to a Bank
    object, and asking for one will not appear in a future version of this document.

    # Vocabulary

    - **Bank** - a payment provider record, addressed by a 24-character hex id.
    - **Playbook** - per-Bank knowledge describing how to examine that provider:
      `sources`, `hunt_steps`, `pdf_maps`, `notes`.
    - **ResearchRun** - a report of an examination you have already finished. There
      is no queued or dispatched run; you create the record after the fact.
    - **Lesson** - structured feedback with a review lifecycle. Yours is always
      stored as `pending`; a human accepts or dismisses it.
    - **ChangeProposal** - an export-shaped suggestion, reviewable by a human only.
    - **base revision** - `exportMetadata.revision`, a sha256 fingerprint of the
      live Bank hierarchy. It is how the server detects that live data moved under
      your proposal.

    # Authentication

    Send `X-Api-Key: <key>` on every operation except this document itself.

    **The key must belong to an admin POSnavigator user.** Any existing, active,
    unexpired API key works if its owner is an admin - no extra scope or permission
    needs to be granted, and the key's `permissions` array is not consulted by
    research operations. A key owned by a non-admin user authenticates successfully
    but receives `403 FORBIDDEN` from every research operation.

    One exception: `getBankExport` also accepts a non-admin key whose owner is
    explicitly linked to that Bank. So a bank-scoped key can read an export but
    cannot use any research operation. If you get 200 from the export and 403 from
    the briefing, your key is bank-scoped, not admin.

    # Response envelope

    Success: `{ "success": true, "data": ... }`.
    Failure: `{ "success": false, "error": { "code", "message", "details"? } }`.

    Branch on `error.code`, never on `error.message` - messages are prose and may
    change. `details` is a list of `{ path, message }` pointing at the offending
    field, and is your best signal for repairing a request.

    # Error codes and what to do about them

    | code | status | what it means | what to do |
    | --- | --- | --- | --- |
    | `MISSING_API_KEY` | 401 | no `X-Api-Key` header | fix the request; do not retry blindly |
    | `INVALID_API_KEY` | 401 | unknown, inactive, or expired key | stop; ask a human for a new key |
    | `FORBIDDEN` | 403 | key owner is not an admin | stop; this key cannot do research |
    | `INVALID_BANK_ID` | 400 | `bankId` is not 24 hex characters | fix the id |
    | `INVALID_ID` | 400 | `proposalId` is not 24 hex characters | fix the id |
    | `INVALID_JSON` | 400 | body is not parseable JSON | fix the body |
    | `BANK_NOT_FOUND` | 404 | no such Bank | drop this Bank from your queue |
    | `PROPOSAL_NOT_FOUND` | 404 | no such proposal for this Bank | re-read the briefing |
    | `RUN_NOT_FOUND` | 404 | referenced run is not this Bank's | fix `run_id` |
    | `LESSON_NOT_FOUND` | 404 | referenced lesson is gone | re-read the briefing |
    | `VALIDATION_ERROR` | 422 | body failed validation | read `details`, repair, retry with a **new** Idempotency-Key |
    | `INVALID_TRANSITION` | 409 | illegal state change | re-read the proposal and re-plan |
    | `STALE_PROPOSAL` | 409 | live data moved since `base_revision` | re-fetch the export and rebuild (see Staleness) |
    | `IDEMPOTENCY_CONFLICT` | 409 | key reused with a different body | use a fresh key |
    | `PROPOSAL_NOT_REVIEWABLE` | 409 | proposal is no longer ready | nothing to do; a human moved it |
    | `PRECONDITION_REQUIRED` | 428 | `If-Match` missing | send the current ETag |
    | `PRECONDITION_FAILED` | 412 | `If-Match` is stale | re-read the resource, re-apply, retry |
    | `RATE_LIMIT_EXCEEDED` | 429 | too many requests | wait `Retry-After` seconds |
    | `INTERNAL_ERROR` | 500 | server fault | retry with backoff; escalate if it persists |

    Retry only 429 and 500. A 4xx other than 429 will not become a success on retry
    without changing the request.

    # Rate limiting

    Every response after a successful authentication - success **and** error -
    carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`
    (Unix epoch seconds). A 429 additionally carries `Retry-After` in seconds. The
    two 401 responses are emitted before rate-limit accounting and carry none of
    these headers. Pace yourself from `X-RateLimit-Remaining` rather than waiting
    to be refused.

    # Idempotency

    `POST` operations require an `Idempotency-Key` header. Generate a fresh UUID
    per logical attempt and reuse it **only** when retrying that exact attempt.

    The key is scoped to (API key, Bank, operation kind), so the same value may be
    reused across different Banks or across runs and proposals without colliding.
    The server also fingerprints the whole request body: replaying the same key with
    an identical body returns the original resource with `200` instead of `201`;
    replaying it with any different body returns `409 IDEMPOTENCY_CONFLICT`. If you
    repair a rejected body, use a new key.

    # Optimistic concurrency

    `PATCH` operations require `If-Match` with the ETag of the resource you read.
    The Playbook ETag comes from `getBankResearchBriefing`; the proposal ETag comes
    from `createBankResearchProposal` or `getBankResearchProposal`. A stale value
    returns `412` and writes nothing - re-read, re-apply your change, retry.

    # The happy path

    1. `GET /openapi/provider-research.yaml` - this document, public.
    2. `listBankResearchSummaries` - pick work in your own scheduler. POSnavigator
       does not rank or dispatch; `last_run_completed_at` and `open_proposal_count`
       are the signals you need.
    3. `getBankResearchBriefing` - Playbook, last run, open proposals, Lessons,
       freshness. Keep the `ETag`; it is the Playbook version.
    4. `getBankExport` - the current state. Keep `exportMetadata.revision`; it is
       your `base_revision`.
    5. Research outside POSnavigator.
    6. If you found changes: `createBankResearchProposal`. Keep the returned `id`.
    7. `reportBankResearchRun` - always, even when nothing changed or you failed.
       Include `proposal_id` when you created one.
    8. Optionally `patchBankResearchPlaybook` or `createBankResearchLesson` when
       you learned something worth keeping.

    If you found **no** change, skip 6 and report a run with `outcome: unchanged`.
    If you could not do the work, report `failed` or `blocked`. A run report is
    never optional - it is how the Bank's freshness timestamp advances.

    # Building a proposal payload

    `payload` is the canonical export shape. **Round-trip the object you got from
    `getBankExport`**: take it verbatim, change the field values you researched,
    and send the whole thing back. Do not hand-build it and do not prune branches
    you did not touch - a missing mainservice reads as a deletion.

    Three rules the server enforces and that reject payloads silently built by hand:

    1. `base_revision`, `payload.exportMetadata.revision`, and the live revision
       must all agree, and `payload.exportMetadata.bankId` and `payload.bank._id`
       must equal the path `bankId`.
    2. **Existing entity ids must belong to this Bank.** Any `_id` that looks like
       a 24-hex ObjectId is checked against the live hierarchy. To propose a *new*
       mainservice, product, feeset or fee, give it a non-ObjectId placeholder id
       such as `"new-pos-terminal-1"`. An unknown ObjectId is rejected as a foreign
       entity, not treated as a create.
    3. **Every changed field needs a justification** before the proposal can become
       `ready_for_review`. Justifications live in the entity-local
       `justifications` tree that the export already ships, mirroring the entity's
       own field names: to justify `bank.dba_name`, set
       `payload.bank.justifications.dba_name` to a non-empty string. There is no
       second, top-level justification shape. A ready transition with a changed
       field and an empty justification returns `422` listing each offending path.

    Create the proposal as `draft` while you are still assembling it and PATCH it
    to `ready_for_review` when complete, or create it as `ready_for_review`
    directly. A ready transition supersedes the Bank's previous ready proposal;
    at most one ready proposal exists per Bank at any time.

    # Staleness

    A proposal is stale when the live revision no longer equals its
    `base_revision` - a human edited the Bank while you were working. Stale
    proposals cannot be approved and cannot be transitioned to ready. There is no
    rebase: fetch a fresh export, rebuild the payload against the new revision, and
    create a new proposal. `getBankResearchProposal` reports `stale` and
    `current_revision` so you can detect this before a human does.

    Playbook edits and legacy freshness edits do **not** stale a proposal; the
    revision deliberately excludes research metadata.

    # Learning

    Learning here means durable Playbook and Lesson data, not model weights. When
    you find a better source or a better procedure, PATCH the Playbook. When you
    want to flag something for a human - a wrong source, a missed PDF, a bad field
    mapping - create a Lesson. Your Lessons are stored `pending`; a human accepts
    them, and only then may an attached `playbook_patch` be applied.
servers:
  - url: https://posnavigator.eu
tags:
  - name: Discovery
  - name: Research
  - name: Playbook
  - name: Runs
  - name: Proposals
  - name: Lessons
  - name: Export
  - name: Freshness
security:
  - ApiKeyAuth: []
paths:
  /openapi/provider-research.yaml:
    get:
      operationId: getProviderResearchOpenApi
      tags:
        - Discovery
      security: []
      summary: Public OpenAPI 3.1 description for provider research
      description: |-
        This document. Public: no API key, no rate limit, no admin check.

        Fetch it first and treat it as the complete manual - every schema, limit,
        example, error code and header an agent needs is here, and no separate
        documentation exists. It is also linked from `/.well-known/agents.json` as the
        `provider_research_spec` endpoint, alongside the `provider_research`
        capability.

        Everything else in this document requires `X-Api-Key` and an admin key owner.
      responses:
        '200':
          description: OpenAPI YAML
          content:
            application/yaml:
              schema:
                type: string
              example: 'openapi: 3.1.0'
  /api/v1/banks/research:
    get:
      operationId: listBankResearchSummaries
      tags:
        - Research
      summary: Cross-Bank research summaries for an external scheduler
      description: |-
        Step 2 of the happy path. One row per Bank with its
        last completed run, open proposal count and Playbook version, so your own
        scheduler can choose what to work on. POSnavigator does not rank, enqueue or
        dispatch work, and there is no "due" signal beyond these timestamps.

        Safe and side-effect free. Not paginated; the whole list is returned.
      responses:
        '200':
          description: Summary list
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BankResearchSummaryListResponse'
              examples:
                success:
                  value:
                    success: true
                    data:
                      - bank_id: 507f1f77bcf86cd799439011
                        dba_name: Raiffeisen
                        company_name: Raiffeisen Bank Zrt.
                        last_run_completed_at: '2026-08-01T10:00:00.000Z'
                        last_run_outcome: proposal_created
                        open_proposal_count: 1
                        playbook_updated_at: '2026-07-20T08:00:00.000Z'
                        playbook_version: 3
          links:
            briefing:
              $ref: '#/components/links/BriefingForBank'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/research/proposals:
    get:
      operationId: listGlobalResearchProposals
      tags:
        - Proposals
      summary: Global proposal inbox
      description: |-
        Cross-Bank proposal inbox, newest first, cursor
        paginated. Defaults to `status=ready_for_review`; pass `status` to see other
        states. Follow `data.next_cursor` until it is null.

        Safe and side-effect free. Use this to check what is already waiting for a human
        before you spend a run producing a duplicate.
      parameters:
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/ProposalStatus'
          example: ready_for_review
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Proposal summaries
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalSummaryPageResponse'
              examples:
                success:
                  value:
                    success: true
                    data:
                      items:
                        - id: 66b0a1c2d3e4f5060708090a
                          bank_id: 507f1f77bcf86cd799439011
                          status: ready_for_review
                          version: 2
                          base_revision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
                          created_at: '2026-08-01T10:00:00.000Z'
                          updated_at: '2026-08-01T10:05:00.000Z'
                          stale: false
                      next_cursor: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/research:
    get:
      operationId: getBankResearchBriefing
      tags:
        - Research
      summary: Per-Bank research briefing
      description: |-
        Step 3 of the happy path, and the only call you need
        before deciding how to examine a Bank. Returns `{ playbook, last_run,
        open_proposals, lessons, freshness }`.

        - `playbook` is always present. When nothing is stored you get a synthesized
          default with `materialized: false` and `version: 0`; **this GET never
          persists it**.
        - `open_proposals` are summaries only - draft and ready_for_review, without
          payloads. Fetch a payload with getBankResearchProposal.
        - `lessons` separates `pending` from `accepted`. Read `accepted` before you
          start: it is the human-approved knowledge about this Bank.
        - `freshness` is the legacy Bank field view, kept for compatibility.

        The `ETag` is the Playbook version. Keep it if you intend to PATCH.

        Safe and side-effect free.
      parameters:
        - $ref: '#/components/parameters/BankId'
      responses:
        '200':
          description: Briefing
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BankBriefingResponse'
              examples:
                success:
                  $ref: '#/components/examples/BriefingSuccess'
          links:
            export:
              $ref: '#/components/links/ExportForBriefedBank'
            playbook:
              $ref: '#/components/links/PlaybookPatchAfterBriefing'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/research/playbook:
    patch:
      operationId: patchBankResearchPlaybook
      tags:
        - Playbook
      summary: Partial Playbook replacement
      description: |-
        Step 8 of the happy path: record that you found a
        better source or a better procedure. Optional, and only worth doing when the
        knowledge generalises to future runs.

        Replaces the provided top-level keys only - `sources`, `hunt_steps`,
        `pdf_maps`, `notes`. **A supplied array replaces that array rather than being
        appended to**, so send the full list you read from the briefing plus your
        change. Keys you omit stay untouched.

        Requires `If-Match` with the briefing's ETag. A stale value returns 412 and
        writes nothing. The first write materializes the synthesized default before
        applying the patch. A successful write atomically updates the Playbook and the
        Bank's mirrored freshness fields, and does **not** stale any open proposal.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/IfMatch'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlaybookPatch'
            examples:
              notesOnly:
                value:
                  notes: Compare POS monthly fees with the latest PDF.
      responses:
        '200':
          description: Updated Playbook
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlaybookResponse'
              examples:
                success:
                  $ref: '#/components/examples/PlaybookSuccess'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '409':
          $ref: '#/components/responses/ProposalConflict'
        '412':
          $ref: '#/components/responses/PreconditionFailed'
        '422':
          $ref: '#/components/responses/ValidationError'
        '428':
          $ref: '#/components/responses/PreconditionRequired'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/research/runs:
    get:
      operationId: listBankResearchRuns
      tags:
        - Runs
      summary: List completed research runs
      description: |-
        Run history for one Bank, newest completed first, cursor
        paginated. Read it to see whether a recent run already covered this provider, or
        what a previous `failed` or `blocked` run ran into.

        Safe and side-effect free.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Run page
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RunPageResponse'
              examples:
                success:
                  value:
                    success: true
                    data:
                      items: []
                      next_cursor: null
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      operationId: reportBankResearchRun
      tags:
        - Runs
      summary: Report a completed research run
      description: |-
        Step 7 of the happy path, and **not optional**: this is
        how the Bank's `last_data_verification_ai_at` and `last_research_outcome`
        advance. Report every examination you finish, including ones that found nothing
        and ones that failed.

        The app never creates a queued run; you record a run that already happened.
        Required: `outcome`, `summary`, `completed_at`. `outcome: proposal_created`
        additionally requires a `proposal_id` belonging to this same Bank, so create the
        proposal first and pass its id here. The server links run and proposal in both
        directions.

        The server records `received_at`, the authenticated API key and user, and
        `actor: agent`. You cannot claim to be a human.

        Requires `Idempotency-Key`. Replaying the same key and body returns the original
        run with 200; a different body with the same key returns 409.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportRunRequest'
            examples:
              proposalCreated:
                value:
                  outcome: proposal_created
                  summary: Fee PDF changed on page 4.
                  completed_at: '2026-08-01T10:00:00.000Z'
                  proposal_id: 66b0a1c2d3e4f5060708090a
      responses:
        '200':
          description: 'Idempotent replay: this Idempotency-Key and body already created a run, and the original is returned unchanged.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RunResponse'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RunResponse'
              examples:
                success:
                  $ref: '#/components/examples/RunSuccess'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/RunReferenceNotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/research/proposals:
    get:
      operationId: listBankResearchProposals
      tags:
        - Proposals
      summary: List proposals for one Bank
      parameters:
        - $ref: '#/components/parameters/BankId'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/ProposalStatus'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Proposal summaries
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalSummaryPageResponse'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
      description: |-
        Proposals for one Bank, newest first, cursor
        paginated. Pass `status` to filter; omit it to see every state including the
        audit trail of approved, rejected and superseded proposals.

        Safe and side-effect free.
    post:
      operationId: createBankResearchProposal
      tags:
        - Proposals
      summary: Create a draft or ready_for_review proposal
      description: |-
        Step 6 of the happy path: store what you found for
        a human to review. This is the only way your research reaches POSnavigator's
        data, and it never touches live records.

        `payload` is the canonical export round-tripped from getBankExport with your
        values changed. `base_revision` must equal `payload.exportMetadata.revision`,
        and both must name the export you actually worked from.

        Create as `draft` while still assembling, or `ready_for_review` to queue it
        immediately. A ready proposal must satisfy two extra rules: the live revision
        must still equal `base_revision` (otherwise 409 STALE_PROPOSAL), and **every
        changed field needs a non-empty justification** in its entity-local
        `justifications` tree (otherwise 422, with each offending path in
        `error.details`). A ready transition atomically supersedes the Bank's previous
        ready proposal, so at most one is ever waiting.

        Existing entity ids are checked against this Bank's live hierarchy; propose new
        entities with non-ObjectId placeholder ids.

        Requires `Idempotency-Key`. Keep the returned `id` for the run report and the
        `ETag` for a later PATCH.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProposalRequest'
            examples:
              draft:
                value:
                  status: draft
                  base_revision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
                  source_urls:
                    - https://example.com/fees.pdf
                  payload:
                    exportMetadata:
                      exportedAt: '2026-08-01T09:00:00.000Z'
                      exportVersion: 1.3.0
                      source: POS Navigator API
                      bankId: 507f1f77bcf86cd799439011
                      bankName: Example
                      revision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
                    bank:
                      _id: 507f1f77bcf86cd799439011
                      dba_name: Example
                      company_name: Example Zrt.
                      website: https://example.com
                      email: info@example.com
                      slug: example
                      isprod: true
                      isbankedit: false
                      transaction_template: t1
                      translations:
                        hu: {}
                        en: {}
                    mainservices: []
                    summary:
                      totalMainservices: 0
                      totalProducts: 0
                      totalFeesets: 0
                      totalFees: 0
      responses:
        '200':
          description: 'Idempotent replay: this Idempotency-Key and body already created a proposal, and the original is returned unchanged.'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalResponse'
          links:
            reportRun:
              $ref: '#/components/links/RunForProposal'
            detail:
              $ref: '#/components/links/ProposalDetail'
        '201':
          description: Proposal stored. Keep data.id for the run report, and the ETag for a later PATCH.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalResponse'
          links:
            reportRun:
              $ref: '#/components/links/RunForProposal'
            detail:
              $ref: '#/components/links/ProposalDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '409':
          $ref: '#/components/responses/ProposalConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/research/proposals/{proposalId}:
    get:
      operationId: getBankResearchProposal
      tags:
        - Proposals
      summary: Get a stored proposal including payload
      description: |-
        Read one stored proposal in full: the payload with its
        entity-local justifications, audit metadata, `base_revision`, the live
        `current_revision`, whether it is `stale` against live data, and the linked run
        summary when a run reported it.

        Check `stale` before assuming a proposal is still actionable - a stale proposal
        cannot be approved or transitioned, and must be rebuilt from a fresh export.

        Safe and side-effect free.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/ProposalId'
      responses:
        '200':
          description: Proposal
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalDetailResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/ProposalNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      operationId: patchBankResearchProposal
      tags:
        - Proposals
      summary: Patch a draft proposal
      description: |-
        Edit a draft, or transition it to
        `ready_for_review`. Only drafts are editable; once ready, payload, sources,
        base revision and justifications are frozen and this returns 409
        INVALID_TRANSITION.

        Requires `If-Match` with the proposal's current ETag. Agents cannot set
        `approved`, `rejected` or `superseded` - those belong to the human review path.
        The same ready-transition rules as creation apply: matching live revision, and a
        justification for every changed field.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/ProposalId'
        - $ref: '#/components/parameters/IfMatch'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchProposalRequest'
            examples:
              ready:
                value:
                  status: ready_for_review
      responses:
        '200':
          description: Updated
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/ProposalNotFound'
        '409':
          $ref: '#/components/responses/ProposalConflict'
        '412':
          $ref: '#/components/responses/PreconditionFailed'
        '422':
          $ref: '#/components/responses/ValidationError'
        '428':
          $ref: '#/components/responses/PreconditionRequired'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/research/lessons:
    post:
      operationId: createBankResearchLesson
      tags:
        - Lessons
      summary: Create a pending Lesson
      description: |-
        Record structured feedback for a human: a wrong
        source, a missed PDF, a bad field mapping, a false positive, or a procedural
        improvement. Use this when the fix is a judgement call; use
        patchBankResearchPlaybook when you are confident and the change is mechanical.

        Agent-created Lessons are always stored `pending`. An attached
        `playbook_patch` is a suggestion and is never auto-applied - a human applies it
        when accepting the Lesson. Accepting and dismissing are admin operations and are
        not exposed on this API.

        Requires `Idempotency-Key`.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLessonRequest'
            examples:
              procedure:
                value:
                  kind: procedure
                  body: Always open the 2026 fee PDF, not the marketing page.
      responses:
        '200':
          description: 'Idempotent replay: this Idempotency-Key and body already created a Lesson, and the original is returned unchanged.'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LessonResponse'
        '201':
          description: Created pending lesson
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LessonResponse'
              examples:
                success:
                  value:
                    success: true
                    data:
                      id: 66b0a1c2d3e4f5060708090b
                      bank_id: 507f1f77bcf86cd799439011
                      kind: procedure
                      body: Always open the 2026 fee PDF, not the marketing page.
                      status: pending
                      proposal_id: null
                      run_id: null
                      playbook_patch: null
                      actor: agent
                      created_at: '2026-08-01T10:00:00.000Z'
                      updated_at: '2026-08-01T10:00:00.000Z'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/LessonReferenceNotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/export:
    get:
      operationId: getBankExport
      tags:
        - Export
      summary: Versioned canonical Bank export
      description: |-
        Step 4 of the happy path: the current state of the Bank
        hierarchy, and the source of the payload you send back in a proposal.

        `exportMetadata.revision` is a sha256 fingerprint of the live hierarchy and is
        your `base_revision`. It deliberately excludes `exportedAt`, `summary`,
        timestamps, generated empty justifications, and Playbook-mirrored research
        metadata, so it changes only when domain data changes.

        Authorization differs from the research operations: an admin key works, and so
        does a non-admin key whose owner is linked to this Bank. Reading an export does
        not imply you may use any research operation.

        Agents must never PATCH live Bank objects; there is no such operation here.
      parameters:
        - $ref: '#/components/parameters/BankId'
        - name: format
          in: query
          schema:
            type: string
            enum:
              - json
              - md
            default: json
          description: json returns the canonical object in data and is the only form usable as a proposal payload. md returns a human-readable rendering in content and cannot be round-tripped.
        - name: mainserviceIds
          in: query
          description: |
            Comma-separated mainservice ids. Narrows the returned payload only;
            exportMetadata.revision always fingerprints the full hierarchy so the
            value stays usable as a proposal base_revision.
          schema:
            type: string
            maxLength: 2000
      responses:
        '200':
          description: Canonical export. With format=json the payload is in data; with format=md it is a rendered document in content, and data is absent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BankExportResponse'
              examples:
                success:
                  $ref: '#/components/examples/ExportSuccess'
                markdown:
                  summary: format=md
                  value:
                    success: true
                    format: markdown
                    filename: Example_export_2026-08-01.md
                    content: |
                      # Example

                      ## Mainservices
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          links:
            createProposal:
              $ref: '#/components/links/ProposalFromExport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/data-freshness:
    get:
      operationId: listBankDataFreshness
      tags:
        - Freshness
      summary: Legacy Bank data freshness list
      description: |-
        Legacy compatibility list of Bank freshness fields,
        including `last_research_outcome`. Prefer listBankResearchSummaries, which
        carries the same signals plus Playbook and proposal state.

        Safe and side-effect free.
      responses:
        '200':
          description: Freshness rows
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FreshnessListResponse'
              examples:
                success:
                  value:
                    success: true
                    data:
                      - _id: 507f1f77bcf86cd799439011
                        dba_name: Example
                        company_name: Example Zrt.
                        website: https://example.com
                        social_urls: []
                        data_verification_urls: []
                        data_verification_instructions: ''
                        last_data_verification_ai_at: null
                        last_data_verification_human_at: null
                        last_research_outcome: null
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/banks/{bankId}/data-freshness:
    get:
      operationId: getBankDataFreshness
      tags:
        - Freshness
      summary: Legacy per-Bank freshness GET
      parameters:
        - $ref: '#/components/parameters/BankId'
      responses:
        '200':
          description: Freshness item
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FreshnessItemResponse'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
      description: |-
        Legacy per-Bank freshness read, kept for
        backward compatibility with the pre-research API.

        Prefer getBankResearchBriefing: it returns this exact slice as its `freshness`
        field, and adds the Playbook, the last run, open proposals and Lessons in the
        same request. Use this route only if you are maintaining an older integration
        that already speaks this shape.

        Safe and side-effect free.
    patch:
      operationId: patchBankDataFreshness
      tags:
        - Freshness
      summary: Legacy freshness PATCH through the shared module
      description: |-
        Legacy freshness write, routed through the same module
        as the Playbook so the two views cannot drift. Prefer
        patchBankResearchPlaybook: this shape can only express sources and notes.

        Existing Playbook sources are matched by normalized URL and keep their metadata;
        minimal metadata is inferred only for newly added URLs. `hunt_steps`,
        `pdf_maps` and Lesson history are left untouched, and canonical state is
        mirrored back to the Bank fields atomically.
      parameters:
        - $ref: '#/components/parameters/BankId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FreshnessPatchRequest'
            examples:
              update:
                value:
                  social_urls: []
                  data_verification_urls:
                    - https://example.com/fees
                  data_verification_instructions: Check the public fee PDF.
                  last_data_verification_ai_at: null
                  last_data_verification_human_at: null
      responses:
        '200':
          description: Updated freshness
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FreshnessItemResponse'
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/BankNotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
  parameters:
    BankId:
      name: bankId
      in: path
      required: true
      schema:
        type: string
        pattern: ^[a-fA-F0-9]{24}$
        maxLength: 24
      example: 507f1f77bcf86cd799439011
      description: POSnavigator Bank id, 24 lowercase hex characters, from listBankResearchSummaries.
    ProposalId:
      name: proposalId
      in: path
      required: true
      schema:
        type: string
        pattern: ^[a-fA-F0-9]{24}$
        maxLength: 24
      description: Proposal id returned by createBankResearchProposal or listed in the briefing.
      example: 66b0a1c2d3e4f5060708090a
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
      description: Page size. Default 25, maximum 100. Out-of-range values return 422.
      example: 25
    Cursor:
      name: cursor
      in: query
      schema:
        type: string
        maxLength: 512
      description: Opaque token from the previous page's data.next_cursor. Omit for the first page; stop when next_cursor is null. Do not construct or parse it.
    IfMatch:
      name: If-Match
      in: header
      required: true
      schema:
        type: string
        maxLength: 32
      example: '"1"'
      description: 'Optimistic concurrency token: the ETag of the resource you read, quoted, e.g. "3". The Playbook ETag comes from getBankResearchBriefing; the proposal ETag from createBankResearchProposal or getBankResearchProposal. Missing returns 428, stale returns 412 and writes nothing.'
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 128
      description: Fresh UUID per logical attempt; reuse only when retrying that exact attempt. Scoped to (API key, Bank, operation kind). Same key with an identical body replays the original resource with 200; same key with a different body returns 409 IDEMPOTENCY_CONFLICT. After repairing a rejected body, use a new key.
      example: 9f1c4d2e-6b7a-4c3d-8e5f-0a1b2c3d4e5f
  headers:
    ETag:
      description: Current version of the resource, quoted. Send it back as If-Match on the next write.
      required: true
      schema:
        type: string
        maxLength: 32
        example: '"1"'
    X-RateLimit-Limit:
      description: Requests allowed in the current window, as a decimal string.
      schema:
        type: string
        maxLength: 16
        example: '100'
    X-RateLimit-Remaining:
      description: Requests left in the current window, as a decimal string. Pace yourself from this rather than waiting for a 429.
      schema:
        type: string
        maxLength: 16
        example: '97'
    X-RateLimit-Reset:
      description: Unix epoch seconds at which the window resets, as a decimal string.
      schema:
        type: string
        maxLength: 16
        example: '1785312000'
    Retry-After:
      description: Seconds to wait before retrying, as a decimal string. Sent with 429 only.
      schema:
        type: string
        maxLength: 16
        example: '60'
  schemas:
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - error
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          additionalProperties: false
          required:
            - code
            - message
          properties:
            code:
              type: string
              maxLength: 64
            message:
              type: string
              maxLength: 2000
            details:
              type: array
              maxItems: 50
              items:
                type: object
                additionalProperties: false
                required:
                  - path
                  - message
                properties:
                  path:
                    type: string
                    maxLength: 500
                  message:
                    type: string
                    maxLength: 500
            retryAfter:
              type: integer
    SuccessEnvelope:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data: true
    BankResearchSummary:
      type: object
      additionalProperties: false
      required:
        - bank_id
        - dba_name
        - company_name
        - open_proposal_count
      properties:
        bank_id:
          type: string
          maxLength: 24
        dba_name:
          type: string
          maxLength: 200
        company_name:
          type: string
          maxLength: 200
        last_run_completed_at:
          type:
            - string
            - 'null'
          format: date-time
        last_run_outcome:
          type:
            - string
            - 'null'
          enum:
            - unchanged
            - proposal_created
            - needs_human
            - failed
            - blocked
            - null
        open_proposal_count:
          type: integer
          minimum: 0
        playbook_updated_at:
          type:
            - string
            - 'null'
          format: date-time
        playbook_version:
          type:
            - integer
            - 'null'
          minimum: 0
    BankResearchSummaryListResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          type: array
          maxItems: 500
          items:
            $ref: '#/components/schemas/BankResearchSummary'
    ProposalStatus:
      type: string
      enum:
        - draft
        - ready_for_review
        - approved
        - rejected
        - superseded
      description: |-
        Proposal lifecycle. You may only create or move a proposal into
        `draft` or `ready_for_review`; the last three are set by the human review path
        and are read-only for agents.
        - `draft` - still being assembled. Content is editable via PATCH.
        - `ready_for_review` - queued for a human. Content is frozen. At most one per
          Bank; a new ready proposal supersedes the previous one.
        - `approved` - a human applied the selected changes to live data.
        - `rejected` - a human declined it; a Lesson records why.
        - `superseded` - a newer ready proposal replaced it.
    ProposalSummary:
      type: object
      additionalProperties: false
      required:
        - id
        - bank_id
        - status
        - version
        - base_revision
        - created_at
        - updated_at
      properties:
        id:
          type: string
          maxLength: 24
        bank_id:
          type: string
          maxLength: 24
        bank_dba_name:
          type: string
          maxLength: 200
        status:
          $ref: '#/components/schemas/ProposalStatus'
        version:
          type: integer
          minimum: 1
        base_revision:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        stale:
          type: boolean
        current_revision:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
    CursorPage:
      type: object
      additionalProperties: false
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          maxItems: 100
        next_cursor:
          type:
            - string
            - 'null'
          maxLength: 512
    ProposalSummaryPageResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          type: object
          additionalProperties: false
          required:
            - items
            - next_cursor
          properties:
            items:
              type: array
              maxItems: 100
              items:
                $ref: '#/components/schemas/ProposalSummary'
            next_cursor:
              type:
                - string
                - 'null'
              maxLength: 512
    SourceType:
      type: string
      enum:
        - pricing_page
        - fee_pdf
        - social
        - press
        - other
      description: |-
        What kind of source this URL is:
        - `pricing_page` - public page listing prices or conditions.
        - `fee_pdf` - downloadable fee or conditions document.
        - `social` - social profile; mirrored to the Bank's social_urls.
        - `press` - press release or newsroom item.
        - `other` - anything else worth revisiting.

        Non-social sources are mirrored to the Bank's data_verification_urls.
    PlaybookSource:
      type: object
      additionalProperties: false
      required:
        - id
        - url
        - type
      properties:
        id:
          type: string
          maxLength: 40
        url:
          type: string
          format: uri
          maxLength: 2048
        type:
          $ref: '#/components/schemas/SourceType'
        locale:
          type: string
          maxLength: 16
        title:
          type: string
          maxLength: 200
        content_hash:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
        note:
          type: string
          maxLength: 2000
      description: A URL worth examining for this Bank, with the metadata needed to revisit it. `content_hash` lets a later run detect that the document changed without re-reading it in full.
    HuntStep:
      type: object
      additionalProperties: false
      required:
        - id
        - order
        - text
      properties:
        id:
          type: string
          maxLength: 40
        order:
          type: integer
          minimum: 1
        text:
          type: string
          maxLength: 20000
      description: One ordered step of the examination procedure. `order` is 1-based and defines the sequence a run should follow.
    PdfMap:
      description: |-
        Instruction for extracting one value from a fee PDF,
        discriminated on `type`. Page numbers are 1-based; regex capture groups are
        0-based, so group 1 is the first parenthesized group.
        - `regex` - first match of `pattern`.
        - `regex_findall` - every match of `pattern`.
        - `table_rows` - rows of table `table_index` on the page, optionally filtered
          to rows containing `row_match_values`.
        - `text_block` - the text between `start` and `end`.
      oneOf:
        - $ref: '#/components/schemas/PdfMapRegex'
        - $ref: '#/components/schemas/PdfMapTable'
        - $ref: '#/components/schemas/PdfMapText'
      discriminator:
        propertyName: type
        mapping:
          regex: '#/components/schemas/PdfMapRegex'
          regex_findall: '#/components/schemas/PdfMapRegex'
          table_rows: '#/components/schemas/PdfMapTable'
          text_block: '#/components/schemas/PdfMapText'
    PdfMapRegex:
      type: object
      additionalProperties: false
      required:
        - id
        - label
        - type
        - pattern
      properties:
        id:
          type: string
          maxLength: 40
        label:
          type: string
          maxLength: 120
        type:
          type: string
          enum:
            - regex
            - regex_findall
        page:
          type: integer
          minimum: 1
        pattern:
          type: string
          maxLength: 2000
        group:
          type: integer
          minimum: 0
        note:
          type: string
          maxLength: 2000
    PdfMapTable:
      type: object
      additionalProperties: false
      required:
        - id
        - label
        - type
      properties:
        id:
          type: string
          maxLength: 40
        label:
          type: string
          maxLength: 120
        type:
          type: string
          const: table_rows
        page:
          type: integer
          minimum: 1
        table_index:
          type: integer
          minimum: 0
        row_match_values:
          type: array
          maxItems: 20
          items:
            type: string
            maxLength: 200
        note:
          type: string
          maxLength: 2000
    PdfMapText:
      type: object
      additionalProperties: false
      required:
        - id
        - label
        - type
      properties:
        id:
          type: string
          maxLength: 40
        label:
          type: string
          maxLength: 120
        type:
          type: string
          const: text_block
        page:
          type: integer
          minimum: 1
        start:
          type: string
          maxLength: 2000
        end:
          type: string
          maxLength: 2000
        note:
          type: string
          maxLength: 2000
    Playbook:
      type: object
      additionalProperties: false
      required:
        - bank_id
        - sources
        - hunt_steps
        - pdf_maps
        - notes
        - version
      properties:
        bank_id:
          type: string
          maxLength: 24
        sources:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/PlaybookSource'
        hunt_steps:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/HuntStep'
        pdf_maps:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/PdfMap'
        notes:
          type: string
          maxLength: 20000
        version:
          type: integer
          minimum: 0
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
        materialized:
          type: boolean
      description: |-
        Per-Bank research knowledge. Always present: when
        nothing is stored, a default is synthesized from the Bank's website, social URLs
        and verification URLs. `materialized: false` means you are looking at that
        synthesized default and nothing is persisted yet; `version` is 0 and the first
        PATCH materializes it.
    PlaybookPatch:
      type: object
      additionalProperties: false
      properties:
        sources:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/PlaybookSource'
          description: Replaces the whole sources array. Omit the key to leave it unchanged.
        hunt_steps:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/HuntStep'
          description: Replaces the whole hunt_steps array. Omit the key to leave it unchanged.
        pdf_maps:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/PdfMap'
          description: Replaces the whole pdf_maps array. Omit the key to leave it unchanged.
        notes:
          type: string
          maxLength: 20000
          description: Replaces the free-text notes. Omit the key to leave it unchanged.
      description: |-
        Partial replacement of the four top-level Playbook
        keys. **A supplied array replaces that array; it is not appended to.** To add one
        source, send the full list you read from the briefing plus the new entry. Keys
        you omit are left untouched, so a notes-only patch never disturbs hunt_steps or
        pdf_maps.

        The first write materializes the synthesized default before applying the patch.
        Entries without an `id` get one generated; send back the ids you read to keep
        them stable across edits.
    PlaybookResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/Playbook'
    DocumentFound:
      type: object
      additionalProperties: false
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        title:
          type: string
          maxLength: 200
        media_type:
          type: string
          maxLength: 128
        content_hash:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
        note:
          type: string
          maxLength: 2000
    RunOutcome:
      type: string
      enum:
        - unchanged
        - proposal_created
        - needs_human
        - failed
        - blocked
      description: |-
        How the examination ended. Pick exactly one:
        - `unchanged` - you completed the research and the provider's public data still
          matches POSnavigator. No proposal.
        - `proposal_created` - you found changes and stored them as a proposal.
          Requires `proposal_id` naming a proposal of this same Bank.
        - `needs_human` - you completed the research but cannot decide safely; a human
          should look. Say what is ambiguous in `summary`.
        - `failed` - you could not complete the research for a technical reason
          (page 404, PDF unreadable, extraction failed). Retryable later.
        - `blocked` - an external obstacle stops you (paywall, login wall, captcha,
          geo-block, robots restriction). Not retryable without a human changing
          something.
    ResearchRun:
      type: object
      additionalProperties: false
      required:
        - id
        - bank_id
        - outcome
        - summary
        - completed_at
        - received_at
        - actor
      properties:
        id:
          type: string
          maxLength: 24
          readOnly: true
        bank_id:
          type: string
          maxLength: 24
          readOnly: true
        outcome:
          $ref: '#/components/schemas/RunOutcome'
        summary:
          type: string
          maxLength: 8000
        started_at:
          type:
            - string
            - 'null'
          format: date-time
        completed_at:
          type: string
          format: date-time
        received_at:
          type: string
          format: date-time
          readOnly: true
        agent_id:
          type:
            - string
            - 'null'
          maxLength: 128
        actor:
          type: string
          const: agent
          readOnly: true
        api_key_id:
          type: string
          maxLength: 24
          readOnly: true
        user_id:
          type: string
          maxLength: 24
          readOnly: true
        visited_urls:
          type: array
          maxItems: 100
          items:
            type: string
            format: uri
            maxLength: 2048
        documents_found:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/DocumentFound'
        proposal_id:
          type:
            - string
            - 'null'
          maxLength: 24
    ReportRunRequest:
      type: object
      additionalProperties: false
      required:
        - outcome
        - summary
        - completed_at
      properties:
        outcome:
          allOf:
            - $ref: '#/components/schemas/RunOutcome'
          description: How the examination ended. See RunOutcome for when to pick which value.
        summary:
          type: string
          minLength: 1
          maxLength: 8000
          description: Human-readable account of what you did and found, written for the admin who will read it later. Say what you checked, what changed, and what you could not verify.
        completed_at:
          type: string
          format: date-time
          description: When the examination finished, ISO-8601. Must not be more than 300 seconds ahead of server time.
        started_at:
          type: string
          format: date-time
          description: When the examination began, ISO-8601. Must be at or before completed_at.
        agent_id:
          type: string
          maxLength: 128
          description: Free-text label for the external platform or agent, e.g. "codex". Human-readable only; it is not trusted identity and never overrides the authenticated API key.
        visited_urls:
          type: array
          maxItems: 100
          items:
            type: string
            format: uri
            maxLength: 2048
          description: Absolute http(s) URLs you actually opened. Normalized and de-duplicated server-side.
        documents_found:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/DocumentFound'
          description: Fee documents or pages worth recording, with optional content_hash so a later run can tell whether the document changed.
        proposal_id:
          type: string
          maxLength: 24
          description: The proposal this run produced. Required when outcome is proposal_created, and must belong to this same Bank.
    RunResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/ResearchRun'
    RunPageResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          type: object
          additionalProperties: false
          required:
            - items
            - next_cursor
          properties:
            items:
              type: array
              maxItems: 100
              items:
                $ref: '#/components/schemas/ResearchRun'
            next_cursor:
              type:
                - string
                - 'null'
              maxLength: 512
    ExportMetadata:
      type: object
      additionalProperties: false
      required:
        - exportedAt
        - exportVersion
        - source
        - bankId
        - bankName
        - revision
      properties:
        exportedAt:
          type: string
          format: date-time
        exportVersion:
          type: string
          maxLength: 16
        source:
          type: string
          maxLength: 100
        bankId:
          type: string
          maxLength: 24
        bankName:
          type: string
          maxLength: 200
        revision:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
          description: |
            Fingerprint of the live Bank hierarchy. Format sha256:<64 lowercase hex>.
            Excludes exportedAt, summary, createdAt/updatedAt, generated empty
            justifications, and the Playbook-mirrored research metadata
            (social_urls, data_verification_urls, data_verification_instructions),
            so a Playbook PATCH never invalidates an open proposal.
    BankExport:
      type: object
      additionalProperties: false
      required:
        - exportMetadata
        - bank
        - mainservices
      properties:
        exportMetadata:
          $ref: '#/components/schemas/ExportMetadata'
        bank:
          type: object
          additionalProperties: true
          required:
            - _id
          properties:
            _id:
              type: string
              maxLength: 24
            dba_name:
              type: string
              maxLength: 200
            company_name:
              type: string
              maxLength: 200
            website:
              type: string
              maxLength: 2048
            email:
              type: string
              maxLength: 200
            slug:
              type: string
              maxLength: 200
            isprod:
              type: boolean
            isbankedit:
              type: boolean
            transaction_template:
              type: string
              maxLength: 64
            translations:
              type: object
              additionalProperties: true
            justifications:
              $ref: '#/components/schemas/FieldJustifications'
        mainservices:
          type: array
          maxItems: 200
          items:
            type: object
            additionalProperties: true
            description: Mainservice as exported, including its own entity-local justifications tree and nested products/feesets/fees.
        summary:
          type: object
          additionalProperties: false
          properties:
            totalMainservices:
              type: integer
              minimum: 0
            totalProducts:
              type: integer
              minimum: 0
            totalFeesets:
              type: integer
              minimum: 0
            totalFees:
              type: integer
              minimum: 0
      description: |-
        Canonical Bank export. Round-trip the object you
        received from `getBankExport`: change the values you researched and send the
        whole object back. Do not hand-build it and do not prune branches you did not
        touch - a missing mainservice reads as a deletion.

        Existing entities keep their 24-hex `_id`, which is checked against this Bank's
        live hierarchy. Propose a new mainservice, product, feeset or fee with a
        non-ObjectId placeholder id such as `"new-pos-terminal-1"`; an unknown ObjectId
        is rejected as a foreign entity rather than treated as a create.

        Beyond the documented keys the shape is passed through as stored, so unlisted
        fields you received are preserved when you send them back.
    BankExportResponse:
      type: object
      additionalProperties: true
      required:
        - success
      properties:
        success:
          type: boolean
        data:
          $ref: '#/components/schemas/BankExport'
        format:
          type: string
          enum:
            - json
            - markdown
        filename:
          type: string
          maxLength: 200
        content:
          type: string
          maxLength: 5000000
    CreateProposalRequest:
      type: object
      additionalProperties: false
      required:
        - payload
        - source_urls
        - status
        - base_revision
      properties:
        payload:
          allOf:
            - $ref: '#/components/schemas/BankExport'
          description: The canonical export, round-tripped from getBankExport with your changes applied.
        source_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
          description: Absolute http(s) URLs backing this proposal. Normalized and de-duplicated server-side.
        status:
          type: string
          enum:
            - draft
            - ready_for_review
          description: Create as draft while still assembling, or ready_for_review to queue it for a human immediately. A ready proposal supersedes the Bank's previous ready proposal and requires a justification for every changed field.
        base_revision:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
          description: exportMetadata.revision from the export you built this payload on. Must equal payload.exportMetadata.revision, and must still match live data to become ready.
    PatchProposalRequest:
      type: object
      additionalProperties: false
      properties:
        payload:
          allOf:
            - $ref: '#/components/schemas/BankExport'
          description: Replacement payload. Omit to keep the stored one.
        source_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
          description: Replacement list; a supplied array replaces the stored array rather than appending.
        base_revision:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
          description: Replacement base revision, when you rebuilt the payload on a fresher export.
        status:
          type: string
          enum:
            - ready_for_review
          description: Only ready_for_review is accepted. Agents cannot set approved, rejected or superseded.
    ChangeProposalBase:
      type: object
      required:
        - id
        - bank_id
        - status
        - version
        - payload
        - source_urls
        - base_revision
      properties:
        id:
          type: string
          maxLength: 24
          readOnly: true
        bank_id:
          type: string
          maxLength: 24
          readOnly: true
        status:
          $ref: '#/components/schemas/ProposalStatus'
        version:
          type: integer
          minimum: 1
          readOnly: true
        payload:
          $ref: '#/components/schemas/BankExport'
        source_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
        base_revision:
          type: string
          pattern: ^sha256:[a-f0-9]{64}$
        run_id:
          type:
            - string
            - 'null'
          maxLength: 24
        created_at:
          type: string
          format: date-time
          readOnly: true
        updated_at:
          type: string
          format: date-time
          readOnly: true
        actor:
          type: string
          const: agent
          readOnly: true
        api_key_id:
          type: string
          maxLength: 24
          readOnly: true
        user_id:
          type: string
          maxLength: 24
          readOnly: true
        reviewer_user_id:
          type:
            - string
            - 'null'
          maxLength: 24
          readOnly: true
        status_history:
          type: array
          maxItems: 20
          items:
            type: object
            additionalProperties: false
            required:
              - status
              - at
              - actor
              - user_id
            properties:
              status:
                $ref: '#/components/schemas/ProposalStatus'
              at:
                type: string
                format: date-time
              actor:
                type: string
                enum:
                  - agent
                  - human
              user_id:
                type: string
                maxLength: 24
    ChangeProposal:
      allOf:
        - $ref: '#/components/schemas/ChangeProposalBase'
      unevaluatedProperties: false
    RunSummary:
      type: object
      additionalProperties: false
      required:
        - id
        - outcome
        - summary
        - completed_at
      properties:
        id:
          type: string
          maxLength: 24
        outcome:
          $ref: '#/components/schemas/RunOutcome'
        summary:
          type: string
          maxLength: 8000
        completed_at:
          type: string
          format: date-time
        agent_id:
          type:
            - string
            - 'null'
          maxLength: 128
    ChangeProposalDetail:
      description: |
        Stored proposal plus the review context a human needs: the live revision,
        whether the proposal is stale against it, and the linked run summary.
      unevaluatedProperties: false
      allOf:
        - $ref: '#/components/schemas/ChangeProposalBase'
        - type: object
          properties:
            current_revision:
              type:
                - string
                - 'null'
              pattern: ^sha256:[a-f0-9]{64}$
              readOnly: true
            stale:
              type:
                - boolean
                - 'null'
              readOnly: true
            run:
              readOnly: true
              oneOf:
                - $ref: '#/components/schemas/RunSummary'
                - type: 'null'
    ProposalResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/ChangeProposal'
    ProposalDetailResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/ChangeProposalDetail'
    CreateLessonRequest:
      type: object
      additionalProperties: false
      required:
        - kind
        - body
      properties:
        kind:
          type: string
          enum:
            - wrong_source
            - missed_pdf
            - bad_field_mapping
            - false_positive
            - procedure
          description: What the feedback is about. See LessonKind.
        body:
          type: string
          minLength: 1
          maxLength: 10000
          description: What a future run should do differently, and why.
        proposal_id:
          type: string
          maxLength: 24
          description: The proposal this feedback concerns, when there is one. Must belong to this Bank.
        run_id:
          type: string
          maxLength: 24
          description: The run this feedback concerns, when there is one. Must belong to this Bank.
        playbook_patch:
          allOf:
            - $ref: '#/components/schemas/PlaybookPatch'
          description: Suggested Playbook change. Stored as a suggestion only and never auto-applied; a human applies it when accepting the Lesson.
    Lesson:
      type: object
      additionalProperties: false
      required:
        - id
        - bank_id
        - kind
        - body
        - status
        - actor
      properties:
        id:
          type: string
          maxLength: 24
          readOnly: true
        bank_id:
          type: string
          maxLength: 24
          readOnly: true
        kind:
          type: string
          enum:
            - wrong_source
            - missed_pdf
            - bad_field_mapping
            - false_positive
            - procedure
        body:
          type: string
          maxLength: 10000
        status:
          type: string
          enum:
            - pending
            - accepted
            - dismissed
        proposal_id:
          type:
            - string
            - 'null'
          maxLength: 24
        run_id:
          type:
            - string
            - 'null'
          maxLength: 24
        playbook_patch:
          type:
            - object
            - 'null'
          additionalProperties: true
        actor:
          type: string
          enum:
            - agent
            - human
          readOnly: true
        reviewer_user_id:
          type:
            - string
            - 'null'
          maxLength: 24
          readOnly: true
          description: Admin who accepted or dismissed the Lesson.
        reviewed_at:
          type:
            - string
            - 'null'
          format: date-time
          readOnly: true
        created_at:
          type: string
          format: date-time
          readOnly: true
        updated_at:
          type: string
          format: date-time
          readOnly: true
    LessonResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/Lesson'
    FreshnessItem:
      type: object
      additionalProperties: false
      required:
        - _id
        - dba_name
        - company_name
        - website
        - social_urls
        - data_verification_urls
        - data_verification_instructions
      properties:
        _id:
          type: string
          maxLength: 24
        dba_name:
          type: string
          maxLength: 200
        company_name:
          type: string
          maxLength: 200
        website:
          type: string
          maxLength: 2048
        social_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
        data_verification_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
        data_verification_instructions:
          type: string
          maxLength: 20000
        last_data_verification_ai_at:
          type:
            - string
            - 'null'
          format: date-time
        last_data_verification_human_at:
          type:
            - string
            - 'null'
          format: date-time
        last_research_outcome:
          type:
            - string
            - 'null'
          enum:
            - unchanged
            - proposal_created
            - needs_human
            - failed
            - blocked
            - null
    FreshnessListResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          type: array
          maxItems: 500
          items:
            $ref: '#/components/schemas/FreshnessItem'
    FreshnessItemResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/FreshnessItem'
    FreshnessPatchRequest:
      type: object
      additionalProperties: false
      required:
        - last_data_verification_ai_at
        - last_data_verification_human_at
      properties:
        social_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
          description: Replaces the Bank's social URL list.
        data_verification_urls:
          type: array
          maxItems: 50
          items:
            type: string
            format: uri
            maxLength: 2048
          description: Replaces the Bank's verification URL list.
        data_verification_instructions:
          type: string
          maxLength: 20000
          description: Replaces the Playbook notes.
        last_data_verification_ai_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Agent verification timestamp, or null to clear.
        last_data_verification_human_at:
          type:
            - string
            - 'null'
          format: date-time
          description: Human verification timestamp, or null to clear.
      description: |-
        Legacy compatibility shape. Prefer
        patchBankResearchPlaybook: this route can only express sources and notes, and
        matches existing Playbook sources by normalized URL, keeping their metadata.
        hunt_steps, pdf_maps and Lesson history are left untouched.
    BankBriefing:
      type: object
      additionalProperties: false
      required:
        - playbook
        - last_run
        - open_proposals
        - lessons
        - freshness
      properties:
        playbook:
          $ref: '#/components/schemas/Playbook'
        last_run:
          type:
            - object
            - 'null'
          additionalProperties: true
        open_proposals:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/ProposalSummary'
        lessons:
          type: object
          additionalProperties: false
          required:
            - pending
            - accepted
          properties:
            pending:
              type: array
              maxItems: 100
              items:
                $ref: '#/components/schemas/Lesson'
            accepted:
              type: array
              maxItems: 100
              items:
                $ref: '#/components/schemas/Lesson'
        freshness:
          $ref: '#/components/schemas/FreshnessItem'
    BankBriefingResponse:
      type: object
      additionalProperties: false
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          const: true
        data:
          $ref: '#/components/schemas/BankBriefing'
    LessonKind:
      type: string
      enum:
        - wrong_source
        - missed_pdf
        - bad_field_mapping
        - false_positive
        - procedure
      description: |-
        What the feedback is about:
        - `wrong_source` - a Playbook source points at the wrong or a dead page.
        - `missed_pdf` - a fee document exists that the Playbook does not list.
        - `bad_field_mapping` - a pdf_map or hunt step extracts the wrong field.
        - `false_positive` - a reported change was not a real change.
        - `procedure` - the examination procedure itself should change.
    FieldJustifications:
      type: object
      description: |-
        Entity-local justification tree. Keys mirror the entity's own
        field names; a value is either the justification string for that field or a
        nested tree for a nested object such as `translations`. The export ships this
        tree pre-built with empty strings, so fill in the fields you changed rather than
        constructing it yourself.

        Every field you change must carry a non-empty justification before the proposal
        can become `ready_for_review`. This is the only justification shape; there is
        no top-level alternative.
      additionalProperties:
        oneOf:
          - type: string
            maxLength: 2000
          - type: object
            additionalProperties: true
      examples:
        - dba_name: Renamed on the public pricing page on 2026-08-01.
          translations:
            hu:
              long_description: New Hungarian copy from the provider site.
  responses:
    Unauthorized:
      description: 'Missing, unknown, inactive or expired API key. Emitted before rate-limit accounting, so this is the only response that carries no X-RateLimit-* headers. Not retryable: fix the key.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            missing:
              value:
                success: false
                error:
                  code: MISSING_API_KEY
                  message: X-Api-Key header is required
            invalid:
              value:
                success: false
                error:
                  code: INVALID_API_KEY
                  message: Invalid API key
    Forbidden:
      description: Non-admin key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            forbidden:
              value:
                success: false
                error:
                  code: FORBIDDEN
                  message: Admin access required
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    InvalidBankId:
      description: Invalid Bank id
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            invalid:
              value:
                success: false
                error:
                  code: INVALID_BANK_ID
                  message: Invalid bank ID format
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    BadRequest:
      description: |
        Malformed request: an unparseable path id (INVALID_BANK_ID, INVALID_ID)
        or a body that is not valid JSON (INVALID_JSON).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            bankId:
              value:
                success: false
                error:
                  code: INVALID_BANK_ID
                  message: Invalid bank ID format
            resourceId:
              value:
                success: false
                error:
                  code: INVALID_ID
                  message: Invalid proposalId format
                  details:
                    - path: proposalId
                      message: Must be a 24 character hex ObjectId
            json:
              value:
                success: false
                error:
                  code: INVALID_JSON
                  message: Invalid JSON body
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    BankNotFound:
      description: Bank not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            missing:
              value:
                success: false
                error:
                  code: BANK_NOT_FOUND
                  message: Bank not found
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    RunReferenceNotFound:
      description: |
        The Bank, or the referenced proposal_id, does not exist for this Bank.
        RUN_NOT_FOUND only appears when an idempotent replay can no longer
        resolve the run it recorded.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            proposal:
              value:
                success: false
                error:
                  code: PROPOSAL_NOT_FOUND
                  message: proposal_id does not belong to this Bank
            run:
              value:
                success: false
                error:
                  code: RUN_NOT_FOUND
                  message: Idempotent run missing
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    RunNotFound:
      description: Run not found, or the referenced run belongs to another Bank
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            missing:
              value:
                success: false
                error:
                  code: RUN_NOT_FOUND
                  message: run_id does not belong to this Bank
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    LessonNotFound:
      description: Lesson not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            missing:
              value:
                success: false
                error:
                  code: LESSON_NOT_FOUND
                  message: Lesson not found
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    LessonReferenceNotFound:
      description: |
        The Bank, or a referenced proposal_id/run_id, does not exist for this
        Bank. LESSON_NOT_FOUND only appears when an idempotent replay can no
        longer resolve the Lesson it recorded.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            proposal:
              value:
                success: false
                error:
                  code: PROPOSAL_NOT_FOUND
                  message: proposal_id does not belong to this Bank
            run:
              value:
                success: false
                error:
                  code: RUN_NOT_FOUND
                  message: run_id does not belong to this Bank
            lesson:
              value:
                success: false
                error:
                  code: LESSON_NOT_FOUND
                  message: Idempotent lesson missing
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    ProposalNotFound:
      description: Proposal not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            missing:
              value:
                success: false
                error:
                  code: PROPOSAL_NOT_FOUND
                  message: Proposal not found
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            fields:
              value:
                success: false
                error:
                  code: VALIDATION_ERROR
                  message: Invalid outcome
                  details:
                    - path: outcome
                      message: Must be one of unchanged, proposal_created, needs_human, failed, blocked
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    InvalidTransition:
      description: Illegal state transition
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            conflict:
              value:
                success: false
                error:
                  code: INVALID_TRANSITION
                  message: Only draft proposals can be patched
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    ProposalConflict:
      description: |
        The proposal cannot move to the requested state. STALE_PROPOSAL means the
        live export revision no longer matches base_revision, so the proposal must
        be rebuilt from a fresh export. INVALID_TRANSITION covers illegal state
        changes, including a second ready_for_review proposal racing the first.
        PROPOSAL_NOT_REVIEWABLE is raised on the admin approve/reject path when
        the proposal is no longer ready_for_review.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            stale:
              value:
                success: false
                error:
                  code: STALE_PROPOSAL
                  message: Live Bank export revision no longer matches proposal base_revision
            transition:
              value:
                success: false
                error:
                  code: INVALID_TRANSITION
                  message: Only draft proposals can be patched
            notReviewable:
              value:
                success: false
                error:
                  code: PROPOSAL_NOT_REVIEWABLE
                  message: Proposal is not ready for review
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    IdempotencyConflict:
      description: Idempotency key reused with a different body
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            conflict:
              value:
                success: false
                error:
                  code: IDEMPOTENCY_CONFLICT
                  message: Idempotency-Key reused with a different body
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    PreconditionFailed:
      description: Stale If-Match
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            stale:
              value:
                success: false
                error:
                  code: PRECONDITION_FAILED
                  message: Resource version does not match If-Match
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    PreconditionRequired:
      description: Missing If-Match
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            missing:
              value:
                success: false
                error:
                  code: PRECONDITION_REQUIRED
                  message: If-Match header is required
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
    RateLimited:
      description: Rate limit exceeded
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            limited:
              value:
                success: false
                error:
                  code: RATE_LIMIT_EXCEEDED
                  message: Too many requests. Please retry after 60 seconds.
                  retryAfter: 60
    InternalError:
      description: Unexpected server error. Retry with backoff; escalate if it persists. Rate-limit headers are present whenever the failure happened after authentication.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            internal:
              value:
                success: false
                error:
                  code: INTERNAL_ERROR
                  message: An internal error occurred
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
  examples:
    BriefingSuccess:
      value:
        success: true
        data:
          playbook:
            bank_id: 507f1f77bcf86cd799439011
            sources:
              - id: src_1
                url: https://example.com
                type: pricing_page
            hunt_steps: []
            pdf_maps: []
            notes: ''
            version: 0
            updated_at: null
            materialized: false
          last_run: null
          open_proposals: []
          lessons:
            pending: []
            accepted: []
          freshness:
            _id: 507f1f77bcf86cd799439011
            dba_name: Example
            company_name: Example Zrt.
            website: https://example.com
            social_urls: []
            data_verification_urls: []
            data_verification_instructions: ''
            last_data_verification_ai_at: null
            last_data_verification_human_at: null
            last_research_outcome: null
    PlaybookSuccess:
      value:
        success: true
        data:
          bank_id: 507f1f77bcf86cd799439011
          sources: []
          hunt_steps: []
          pdf_maps: []
          notes: Compare POS monthly fees with the latest PDF.
          version: 1
          updated_at: '2026-08-01T10:00:00.000Z'
          materialized: true
    RunSuccess:
      value:
        success: true
        data:
          id: 66b0a1c2d3e4f5060708090c
          bank_id: 507f1f77bcf86cd799439011
          outcome: failed
          summary: Pricing page returned 403.
          started_at: null
          completed_at: '2026-08-01T10:00:00.000Z'
          received_at: '2026-08-01T10:00:01.000Z'
          agent_id: codex
          actor: agent
          api_key_id: 66b0a1c2d3e4f50607080901
          user_id: 66b0a1c2d3e4f50607080902
          visited_urls:
            - https://example.com/fees
          documents_found: []
          proposal_id: null
    ExportSuccess:
      value:
        success: true
        data:
          exportMetadata:
            exportedAt: '2026-08-01T09:00:00.000Z'
            exportVersion: 1.3.0
            source: POS Navigator API
            bankId: 507f1f77bcf86cd799439011
            bankName: Example
            revision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          bank:
            _id: 507f1f77bcf86cd799439011
            dba_name: Example
            company_name: Example Zrt.
            website: https://example.com
            email: info@example.com
            slug: example
            isprod: true
            isbankedit: false
            transaction_template: t1
            translations:
              hu: {}
              en: {}
          mainservices: []
          summary:
            totalMainservices: 0
            totalProducts: 0
            totalFeesets: 0
            totalFees: 0
  links:
    BriefingForBank:
      operationId: getBankResearchBriefing
      parameters:
        bankId: $response.body#/data/0/bank_id
      description: 'Step 3: read the Playbook, last run, open proposals and Lessons for this Bank.'
    ExportForBriefedBank:
      operationId: getBankExport
      parameters:
        bankId: $request.path.bankId
      description: 'Step 4: fetch the current state. exportMetadata.revision becomes your base_revision.'
    ProposalFromExport:
      operationId: createBankResearchProposal
      parameters:
        bankId: $request.path.bankId
      requestBody:
        base_revision: $response.body#/data/exportMetadata/revision
        payload: $response.body#/data
      description: 'Step 6: store the changed export as a proposal.'
    RunForProposal:
      operationId: reportBankResearchRun
      parameters:
        bankId: $request.path.bankId
      requestBody:
        proposal_id: $response.body#/data/id
        outcome: proposal_created
      description: 'Step 7: report the completed run that produced this proposal.'
    ProposalDetail:
      operationId: getBankResearchProposal
      parameters:
        bankId: $request.path.bankId
        proposalId: $response.body#/data/id
      description: Re-read the stored proposal to check stale and current_revision.
    PlaybookPatchAfterBriefing:
      operationId: patchBankResearchPlaybook
      parameters:
        bankId: $request.path.bankId
      description: 'Step 8: record a better source or procedure. Use this response ETag as If-Match.'
