openapi: 3.1.0

info:
  title: Facturia API
  version: "1.0.0"
  summary: Multi-tenant Chilean electronic invoicing (DTE), reception and tax automation.
  description: |
    The machine-readable contract for the Facturia v1 public surface. The prose specification —
    with rationale, curl examples and the estado mapping tables — lives in
    `public-api-spec.md` alongside this file and is the normative document where the two differ.

    PUBLISHED AT https://docs.facturia.cl/openapi.yaml, and as JSON at
    https://docs.facturia.cl/openapi.json. The JSON is generated from this exact YAML at build
    time, so the two cannot drift; either is codegen-ready.

    `info.version` is the version of THIS DOCUMENT, not a release channel you can select.
    It moves by semver against the surface it describes: PATCH for wording, MINOR when paths,
    operations or fields are ADDED — which is the only kind of change `/v1` makes — and MAJOR only
    ever alongside the twelve-month withdrawal notice of `public-api-spec.md` §5.8. So a client
    generated from `1.x` keeps compiling against every later `1.y`.

    LANGUAGE, ONCE, FOR THE WHOLE DOCUMENT (#512). Two languages, on purpose, and which one a
    field carries is a property of WHO READS IT:

      * ENGLISH — everything a program consumes: field names, enum values, `error.code`,
        `error.message` and `error.hint`. Machine text. Translate it if you show it to a person;
        do not branch on it (branch on `code`).
      * CHILEAN SPANISH — every string written for the PERSON WHO PERFORMS AN ACT, meant to be
        relayed VERBATIM to them: `pending_action.*` (`what`, `where`, `instructions[]`,
        `bring[]`, `how_we_know`), `Onboarding.steps[].label` and `.help`,
        `Onboarding.requirements[].label`, `blocked_reason.message` and `blocked_reason.hint`,
        `OnboardingRecheck.message`,
        `Mandato` display text, and every `message` on the `/account/*` bootstrap surface. These
        are not error strings: they are the words a human is supposed to read on a screen or hear
        from an agent, and a translation of them is a worse translation than ours. Relay them as
        they arrive.

    A field whose description says «Spanish.» is in the second group. Anything unmarked is in the
    first.

    Conventions:
      * Amounts are CLP integers. Unit prices may carry up to 4 decimals.
      * Civil dates (`issue_date`, `period`) are America/Santiago. Timestamps are RFC 3339 UTC.
      * RUTs are canonical `########-D` (no dots, uppercase K).
      * Lists are cursor-paginated and share the `List` envelope.
      * Every empresa-scoped call carries the `Facturia-Empresa` header, unless the key is an
        empresa token (a key whose allowlist holds exactly one empresa). The header is the ONLY
        mechanism — there is no `?empresa=` query parameter and no `empresa` body field.
      * The key prefix decides the mode: `sk_test_` = fully mocked sandbox, `sk_live_` = real SII.
      * Rate limits are published starting limits, applied per key, and raised on request.
      * `/v1` is the whole version contract and evolves additively. There is no dated-version
        header in v1; a Stripe-style dated release channel is the v2 mechanism if one is ever needed.
      * Documentation: https://docs.facturia.cl

    WHERE THE FIRST KEY COMES FROM. Every operation here authenticates with an `sk_` key, and a
    brand-new account has none. Keys are minted from the BOOTSTRAP SURFACE, which has its own
    machine contract because it cannot honestly share this one: it answers outside `/v1`, on a
    different base URL, with an account JWT rather than a key, and — on `/account/*` — in a
    different error envelope. A single document's `servers` and `security` apply to every path in
    it, so folding the two together would declare URLs and credentials that do not exist.

    So it is published alongside, and it is a complete OpenAPI 3.1 document of its own:

        https://docs.facturia.cl/openapi-bootstrap.yaml
        https://docs.facturia.cl/openapi-bootstrap.json

    Generate a client from THAT one first. The flow it describes is `POST /account/register`
    (or `/account/login`) for a 1-hour session token, then `POST /dashboard/api_keys` with that
    token and a `Facturia-Mode: test|live` header, which returns the `sk_test_` key every
    operation below expects. Prose: `public-api-spec.md` §3.7.

    RESPONSE HEADERS, on every operation below unless noted:
      * `Facturia-Request-Id` — `req_…`, on every response, success or failure. Quote it in support.
      * `RateLimit-Limit` / `-Remaining` / `-Reset` — on every AUTHENTICATED response, reporting the
        tightest applicable bucket. Absent from the unauthenticated `Meta` operations.
      * `Retry-After` — on `429`, on `409 idempotency_in_progress`, and on every other refusal
        whose `retriable` is `true`. A refusal that says «come back» now always says when: the
        same number also travels in the body as `error.retry_after_seconds`, for a consumer that
        stored the JSON and no longer has the headers.
      * `Facturia-Idempotent-Replay: true` — on a replayed `POST`, alongside the original body.
      * `Facturia-Poll-After` — seconds until the next USEFUL poll: on `GET /documents/{document_id}`,
        on the `201` of `POST /documents` (the response that answers `queued`, so the cadence is
        there without a first read), on `GET /jobs/{job_id}`, and on any retriable refusal
        alongside `Retry-After`. Absent once a document or job is terminal. On a job the cadence is
        a function of `type`: what a job waits on differs by an order of magnitude, and the floor
        under all of them is the queue's own 10-second claim interval.
    `Facturia-Mode` is a REQUEST header, and only on the bootstrap surface above.
    `Facturia-Delivery-Id` is a header we SEND on an outbound webhook delivery (see `webhooks`).

    Nested DTE detail (item taxes, dispatch variants, reference codes) is modelled
    representatively rather than exhaustively; the prose spec and the error dictionary at
    `GET /error_codes` are authoritative for edge cases.
  termsOfService: https://facturia.cl/legal/terminos
  contact:
    name: Facturia developer support
    url: https://docs.facturia.cl
    email: dev@facturia.cl
  license:
    name: Documentation licensed CC BY 4.0
    url: https://creativecommons.org/licenses/by/4.0/

servers:
  - url: https://api.facturia.cl/v1
    description: Production. The key prefix (sk_test_ / sk_live_) selects sandbox or live behaviour.

security:
  - bearerAuth: []

tags:
  - name: Account
    description: The authenticated account and its keys.
  - name: Empresas
    description: Tenants. Creation, certificate custody, mandato, onboarding, automation settings.
  - name: Folios
    description: CAF and folio availability per doc type.
  - name: Documents
    description: Emission of DTEs (33/34/39/41/46/52/56/61) and their artifacts.
  - name: Received documents
    description: Supplier DTEs and the respuesta comercial (aceptación / reclamo).
  - name: Tax
    description: RCV, caracterización, F29 propuesta and filing, tipo-46 suggestions.
  - name: Connectors
    description: |
      First-party sources that feed the expense ledger: the Fintoc bank/card movement feed and
      cartola (CSV/XLSX) upload. Ad-platform billing connectors (Meta, Google Ads) are deferred
      past v1 — Fintoc and cartola catch the same charges from the paying end.
  - name: Webhooks
    description: Endpoint management, event ledger, delivery inspection and replay.
  - name: Usage
    description: The immutable usage ledger. Reporting, not a meter — the plan is flat and unlimited.
  - name: Test helpers
    description: Test-mode-only endpoints that drive the outside world.
  - name: Meta
    description: Unauthenticated reference data.

paths:

  # ---------------------------------------------------------------- Account

  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Retrieve the account and the key in use
      description: "The authenticated account and the credential in use — email, plan, empresas_ready/total, and the calling key's own scopes, mode and allowlist."
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
              examples:
                default:
                  summary: "An account with one empresa, all set up."
                  value:
                    object: account
                    id: acct_01K2QW7X9M5P3R
                    name: CONNECT SpA
                    key:
                      id: key_01K2QWB7T4N9
                      object: api_key
                      name: Servidor de producción
                      mode: live
                      root: false
                      last4: aB3d
                      scopes:
                      - documents:read
                      - documents:write
                      empresas:
                      - 77928532-4
                      created_at: '2026-06-21T14:02:00Z'
                      last_used_at: '2026-09-02T11:47:00Z'
                    empresas_ready: 1
                    empresas_total: 1
                    limits:
                      requests_per_minute: 300
                      emissions_per_second: 10
        "400":
          description: |
            `empresa_header_forbidden` — this operation is account-level; a `Facturia-Empresa`
            header on it is refused rather than silently ignored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /api_keys:
    get:
      tags: [Account]
      operationId: listApiKeys
      summary: List API keys (metadata only; secrets are never returned)
      description: |
        A root key sees every key on the account. An ordinary key sees only itself.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A list of API keys.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Account]
      operationId: createApiKey
      summary: Mint an API key (root key only)
      description: |
        Requires a root key (`sk_live_root_…` / `sk_test_root_…`) carrying `keys:write`.
        Guardrails, enforced server-side: the new key's `empresas` must be a subset of the
        minter's allowlist and its `scopes` a subset of the minter's scopes (violations return
        `403 privilege_escalation`); mode is inherited from the minter; root keys cannot mint
        root keys. The `secret` is returned exactly once.
      security:
        - bearerAuth: [keys:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string }
                mode:
                  $ref: "#/components/schemas/Mode"
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/Scope" }
                empresas:
                  type: array
                  description: Subset of the minting key's allowlist. A single RUT makes it an empresa token.
                  items: { $ref: "#/components/schemas/Rut" }
      responses:
        "201":
          description: The minted key, including its secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /api_keys/{key_id}:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    delete:
      tags: [Account]
      operationId: revokeApiKey
      summary: Revoke an API key immediately (root key only)
      description: "Revoke a key immediately and irreversibly. Requires a root key or an account session (`403 root_key_required`) — an ordinary key cannot revoke keys, itself included."
      security:
        - bearerAuth: [keys:write]
      responses:
        "204": { description: Revoked. Revocation is immediate and unrecoverable. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /api_keys/{key_id}/roll:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    post:
      tags: [Account]
      operationId: rollApiKey
      summary: Issue a replacement secret for a key (root key only)
      description: |
        The previous secret keeps working for 24 hours so you can deploy without downtime.

        The roll is a compare-and-swap on the key's current hash, so two rolls of the
        same key can never both land: exactly one wins and the loser answers
        `409 api_key_roll_conflict`, whose secret is the live one. Roll once per key.
      security:
        - bearerAuth: [keys:write]
      responses:
        "200":
          description: The key with its new secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # --------------------------------------------------------------- Empresas

  /empresas:
    get:
      tags: [Empresas]
      operationId: listEmpresas
      summary: List empresas visible to this key
      description: "List every empresa this key can reach — its own allowlist resolved to real rows, or the whole account for an unrestricted key."
      security:
        - bearerAuth: [empresas:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/EmpresaStatus" }
      responses:
        "200":
          description: A list of empresas.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Empresa" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Empresas]
      operationId: createEmpresa
      summary: Create an empresa
      description: |
        In test mode the empresa is born `emitting`: no mandato, no certificate, no certification.
        In live mode it starts at `mandato_pending` and progresses through the onboarding state machine.

        **`origen` IS REQUIRED IN LIVE MODE** and this operation refuses without it —
        `400 invalid_field` with `param: "origen"` (#495). It is not in `required` above because
        the mode is a property of the CREDENTIAL, not of the body, and a schema cannot say «required
        when the bearer token starts with `sk_live_`»: declaring it unconditionally would make every
        sandbox call — where it defaults to `ninguno` — fail a generated client's own validation
        before it left the machine. So the rule lives on the `400` below, where a codegen'd client
        can at least see it, and in the field's own description.
      security:
        - bearerAuth: [empresas:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmpresaCreateRequest" }
      responses:
        "201":
          description: The created empresa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Empresa" }
        "400":
          description: |
            Malformed input. The codes this operation answers:

              * `invalid_rut` — `rut` missing, or its check digit is wrong.
              * `invalid_field` with `param: "legal_name"` — absent or empty.
              * `invalid_field` with `param: "origen"` — **absent, on a live key.** The first
                empresa of every real customer hits this if the caller treated the field as
                optional because the schema does. See the operation description.
              * `invalid_field` with `param: "doc_types"` — not an array of supported tipos, or
                an explicit `[]`.
              * `invalid_field` with `param: "emission_channel"` / `"acteco"` /
                `"codigo_sucursal"` — a value outside what the field admits.
              * `invalid_field` with `param: "ambiente"` — `ambiente` is derived and is refused
                rather than ignored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422":
          description: |
            `doc_type_not_supported_on_channel` — `doc_types` names a tipo the resolved
            `emission_channel` cannot build (39/41 on `sfg`). `error.param` is `doc_types`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getEmpresa
      summary: Retrieve an empresa
      description: "One empresa: legal identity, `ambiente`, `emission_channel`/`certification_track`, onboarding spine and `capabilities`."
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The empresa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Empresa" }
              examples:
                default:
                  summary: "An empresa emitting live on the mercado channel."
                  value:
                    object: empresa
                    id: emp_01K2R6ZC4P8QW1VN7T3MHDY9EK
                    mode: live
                    rut: 77928532-4
                    legal_name: CONNECT SpA
                    giro: Servicios de tecnología
                    acteco:
                    - 620200
                    address:
                      street: Av. Providencia 1208, of. 504
                      comuna: Providencia
                      city: Santiago
                    codigo_sucursal: null
                    contact_email: contacto@connect.cl
                    ambiente: produccion
                    emission_channel: mercado
                    certification_track: mercado
                    doc_types:
                    - 33
                    - 34
                    - 39
                    - 41
                    - 46
                    - 52
                    - 56
                    - 61
                    origen: mercado
                    reception_email: 77928532-4@dte.facturia.cl
                    status: active
                    iva_shape: null
                    onboarding:
                      status: emitting
                      progress: 1
                      blocked_on: null
                      next_action_url: null
                      url: https://api.facturia.cl/v1/empresas/emp_01K2R6ZC4P8QW1VN7T3MHDY9EK/onboarding
                    capabilities:
                      can_emit: true
                      tipos:
                      - 33
                      - 34
                      - 39
                      - 41
                      - 46
                      - 52
                      - 56
                      - 61
                      blocked_by: null
                      next:
                        who: agent
                        api: POST /v1/documents
                        url: null
                    created_at: '2026-06-21T09:15:00Z'
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    patch:
      tags: [Empresas]
      operationId: updateEmpresa
      summary: Update an empresa
      description: |
        Update `legal_name`, `giro`, `contact_email`, `acteco`, `origen`, `codigo_sucursal`,
        `doc_types`, `emission_channel`, `certification_track` or `status`. `ambiente` is
        derived and rejected if sent (`422`); `origen` is the titular's own claim about how the
        empresa emits today, correctable, never a canal preference.

        Two of these move the onboarding machine and say so in their own descriptions:
        `doc_types` (adding a tipo re-opens certification for it; 39/41 need the `mercado`
        channel) and `certification_track` (which checklist governs the empresa).

        THE PANEL MIRROR IS NARROWER. `PATCH /dashboard/empresas/{empresa_id}` — the same
        handler behind an account session — accepts only `doc_types` and `certification_track`,
        and refuses every other field with `400 invalid_field` naming it. Those two are what the
        puesta en marcha wizard has to record; the rest are the taxpayer identity each document
        is printed from, and a browser session is not the credential that rewrites what the SII
        sees on an invoice. An `sk_` key on THIS path is unaffected.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EmpresaUpdateRequest" }
      responses:
        "200":
          description: The updated empresa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Empresa" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Empresas]
      operationId: deleteEmpresa
      summary: Delete a SANDBOX empresa
      description: |
        Throw away a scratch empresa. **Sandbox only.**

        A sandbox empresa holds nothing that is a tax record: its documents are mocked, it never
        reached the SII, and it reserves nothing — not the RUT (uniqueness is per mode) and not the
        `<rut>@dte.facturia.cl` reception alias, which live empresas alone carry. So it can be
        deleted, and the empresa plus everything hanging off it goes in one transaction.

        A LIVE empresa is `409 empresa_delete_not_allowed`, always. §7.1: emitted documents are tax
        records. The two real exits are `DELETE /v1/empresas/{empresa_id}/mandato` (ends the
        authorization, cláusula 7.1.3) and `PATCH /v1/empresas/{empresa_id}` with
        `{"status": "archived"}` (stops emission and billing); both keep the history.
      security:
        - bearerAuth: [empresas:write]
      responses:
        "204":
          description: Deleted. No body.
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            `empresa_delete_not_allowed` — the empresa is live. Revoke the mandato and/or archive it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/certificate:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getCertificate
      summary: Retrieve certificate metadata
      description: Metadata only. The .pfx and its password are never readable through the API.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: Certificate metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Certificate" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Empresas]
      operationId: uploadCertificate
      summary: Upload (or reuse) the empresa's digital certificate
      description: "Upload the empresa's `.pfx` (`multipart/form-data` with `file` and `password`) or reuse another empresa's valid certificate (`reuse_from_empresa`). An expired certificate is refused at ingest."
      security:
        - bearerAuth: [onboarding:write]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: PKCS#12 (.pfx) file.
                password:
                  type: string
                  description: The .pfx password.
                reuse_from_empresa:
                  type: string
                  description: |
                    Instead of uploading, reference a certificate already held for another empresa
                    on this account. Valid only when that certificate is a SII-authorized user of
                    this empresa's RUT.
      responses:
        "201":
          description: Certificate stored and verified.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Certificate"
                  - type: object
                    description: |
                      WHAT FOLLOWS, ON THE RECEIPT (#536, extended to this door by the
                      2026-09-04 verification pass). The upload enqueues the certificate
                      verification and the permisos probe, so the checklist is about to move;
                      these two fields say where to look and whether anything is waiting on a
                      person right now.
                    properties:
                      next:
                        allOf:
                          - $ref: "#/components/schemas/ApiCall"
                        description: The onboarding read that reflects this upload once the probes run.
                      pending_action:
                        oneOf:
                          - $ref: "#/components/schemas/PendingAction"
                          - type: "null"
                        description: |
                          The handoff of whatever the checklist is waiting on AS OF THIS RESPONSE —
                          the same object `GET .../onboarding` publishes, from the same builder.
                          `null` when the next step is ours, which is different from "nothing left".
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Empresas]
      operationId: deleteCertificate
      summary: Purge the certificate from the vault (revokes custody, suspends emission)
      description: "Remove the stored certificate metadata. The empresa returns to needing one before it can sign anything the SII will accept."
      security:
        - bearerAuth: [empresas:write]
      responses:
        "204": { description: Purged. }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/sfg_credential:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getSfgCredential
      summary: Retrieve the SFG centralizado clave's custody metadata
      description: |
        Metadata only — whether we hold the clave and when it was stored. The value itself is
        never readable through the API: there is no code path in this endpoint that decrypts it.

        Only an `emission_channel: sfg` empresa in live mode has one. A mercado empresa signs
        with the certificate in custody and never asks the SII to sign for it, and a test-mode
        empresa has no Portal MIPYME at all; both answer `422` naming the fix.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The custody record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SfgCredential" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Empresas]
      operationId: setSfgCredential
      summary: Store (or replace) the SFG centralizado clave
      description: |
        The value goes straight to the per-tenant vault and its id to the empresa's credential
        row; there is no plaintext column. Replacing destroys the outgoing secret first, so the
        destruction is in the custody record rather than a side effect nobody can point at.

        The response is the same metadata `GET` returns. The clave is not echoed back.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SfgCredentialCreateRequest" }
      responses:
        "201":
          description: Stored.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SfgCredential"
                  - type: object
                    description: |
                      The same three fields the certificate door publishes (#529, #536): the
                      clave is in the vault, a tick was queued to fold it into the checklist,
                      and this is what the checklist is waiting on now.
                    properties:
                      queued:
                        type: boolean
                        description: Whether the onboarding tick that folds the new fact was enqueued.
                      next:
                        allOf:
                          - $ref: "#/components/schemas/ApiCall"
                        description: The onboarding read that reflects this save.
                      pending_action:
                        oneOf:
                          - $ref: "#/components/schemas/PendingAction"
                          - type: "null"
                        description: |
                          The next human handoff, or `null` when the following step is ours.
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Empresas]
      operationId: deleteSfgCredential
      summary: Destroy the SFG centralizado clave (ends custody)
      description: |
        Destroys the bytes. `204` even when there was nothing to destroy — you asked for a state
        ("you hold nothing of mine"), and that state is the outcome either way.

        Unlike `DELETE .../certificate` it does NOT walk the empresa's onboarding back: the next
        SFG emission refuses for want of a signing clave, which the emit path already reports
        honestly, and a clave can be re-supplied in one call.
      security:
        - bearerAuth: [empresas:write]
      responses:
        "204": { description: Destroyed. }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/mandato:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getMandato
      summary: Whether the representante legal has signed, against which terms, and how
      description: |
        The read half of the hosted mode. Mint a `signing_url` with an empty `POST`, send it to
        the representante legal, then poll this - completion is observable by the agent that
        asked for it, without subscribing to a webhook or inferring it from `steps[].status`.

        Two fields answer questions the onboarding checklist cannot. `terms_current: false` on a
        `signed` mandato means the text has moved on and the pipeline has already stopped
        trusting the acceptance - `POST` again to collect a fresh one. And `acceptance_method`
        distinguishes an *aprobacion* (`hosted`: a human of the required role completed a page
        that showed them what they were signing) from an *atestacion* (`api`: an integrator's
        claim that one did). Those are different pieces of evidence.

        `signing_url` is ALWAYS `null` here: only a SHA-256 of the token is stored, so no read
        can reproduce it. `POST` with an empty body re-mints, revoking the previous link.

        `status: "none"` for an empresa nobody has asked yet. It is a state, not a missing
        resource - the empresa exists and you are authorized for it.

        Scope: `mandato:write` (which `empresas:write` implies). The scope that mints the link
        is the scope that reads whether it was used.
      security:
        - bearerAuth: [mandato:write]
      responses:
        "200":
          description: The mandato, or a `none` status when nobody has been asked yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mandato" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Empresas]
      operationId: createMandato
      summary: Record the mandato authorizing Facturia to act before the SII
      description: |
        Post the acceptance you collected yourself, or omit the body to receive a hosted
        `signing_url` to send the representante legal.

        We record that an acceptance happened, by whom, from where, and against which exact
        bytes of the mandato text. We do NOT verify a firma electrónica avanzada: whether one
        is required is an open legal question, and until it is answered this endpoint claims
        no more than the evidence it holds.

        While `GET /v1/mandato_terms` reports `status: "draft"`, a live-mode acceptance is
        refused with `422 mandato_terms_draft` — the same warning §7.3 prints, enforced.

        **Two forms, two scopes (ADR-0012 §4).** No body — mint the enlace de firma for the
        representante legal — needs `mandato:write`, which `empresas:write` implies. A body —
        asserting a signature you collected in your own flow — is an *atestación* and needs
        `mandato:attest` **itself**: `empresas:write` does NOT imply it (corrected 2026-09-03), so
        a key that used to post a body under `empresas:write` alone now gets
        `403 insufficient_scope` naming `mandato:attest`.
      security:
        - bearerAuth: [mandato:write]
        - bearerAuth: [mandato:attest]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MandatoCreateRequest" }
      responses:
        "201":
          description: The mandato.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mandato" }
              examples:
                default:
                  summary: "Empty body: mints the signing link for the representante legal."
                  value:
                    object: mandato
                    empresa_id: emp_01K2R6ZC4P8QW1VN7T3MHDY9EK
                    status: pending
                    terms_version: '1.0'
                    terms_hash: sha256:6447ef0de8961…
                    terms_current: true
                    representante_legal: null
                    signing_url: https://app.facturia.cl/firmar/mandato/tok_9F3kQ2wZ8pR1x7bN0m2Y4hLQ
                    signing_expires_at: '2026-09-09T15:04:00Z'
                    signed_at: null
                    acceptance_method: null
                    revoked_at: null
                    pending_action:
                      object: pending_action
                      act: mandato
                      step: mandato
                      empresa_id: emp_01K2R6ZC4P8QW1VN7T3MHDY9EK
                      action_url: https://app.facturia.cl/firmar/mandato/tok_9F3kQ2wZ8pR1x7bN0m2Y4hLQ
                      link:
                        mint:
                          method: POST
                          url: https://api.facturia.cl/v1/empresas/emp_01K2R6ZC4P8QW1VN7T3MHDY9EK/mandato
                          body: {}
                        ttl_seconds: 604800
                        reusable: false
                      title: Firmar el mandato que nos autoriza a actuar ante el SII
                      summary: Es el contrato que nos permite gestionar tus documentos tributarios ante el SII por cuenta
                        tuya. Lo lee y lo acepta el representante legal, en una página nuestra, y se puede revocar en cualquier
                        momento.
                      sii_url: null
                      performed_by:
                        role: representante_legal
                        label: El representante legal de la empresa.
                        why_not_delegable: 'Es el acto por el que se otorga el mandato: quien lo firma tiene que ser quien
                          puede obligar a la empresa. Sin él no podemos actuar ante el SII por ti en absoluto.'
                        legal_basis:
                        - Mandato Facturia, cláusula 2.5 «Suscripción con firma electrónica simple»
                        - Código Civil, artículo 2116 y siguientes
                      prerequisites:
                      - key: identidad_rep_legal
                        label: Nombre, RUT y correo del representante legal que va a aceptar.
                      - key: rut_empresa
                        label: 'El RUT de la empresa: 77928532-4.'
                      instructions:
                      - order: 1
                        text: Abre el enlace de firma que te entregamos. Es de un solo uso y caduca; si lo perdiste, pide
                          otro y el anterior queda revocado.
                      - order: 2
                        text: 'Lee el contrato completo. Presta atención a la cláusula 4.6: enumera los actos que la ley reserva
                          a tu representante legal y que nosotros no ejecutaremos nunca por ti.'
                      - order: 3
                        text: Acepta. Guardamos la versión aceptada, su huella SHA-256, la fecha, la IP y quién aceptó, para
                          que puedas verificar después que el texto no cambió.
                      consequences:
                      - 'La aceptación queda registrada con firma electrónica simple. Si prefieres firma electrónica avanzada,
                        pídenoslo: está previsto en el contrato.'
                      - Puedes revocar el mandato cuando quieras y con efecto inmediato. La revocación no deshace nada de
                        lo ya hecho.
                      verification:
                        method: own_record
                        description: La aceptación se registra en el mismo acto en nuestros sistemas. No hay nada que consultarle
                          al SII.
                        recheck_url: POST /v1/empresas/emp_01K2R6ZC4P8QW1VN7T3MHDY9EK/onboarding/steps/mandato/recheck
                        sii_latency: 'Inmediato: no interviene el SII.'
                      estimated_minutes: 10
                      next: Con el mandato firmado te pedimos el certificado digital y arrancamos el proceso.
                      evidence: captured
                      caveat: null
                      agent_actions: []
                      copy: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Empresas]
      operationId: revokeMandato
      summary: Revoke the mandato
      description: |
        Cláusula 7.1: at any time, without cause, without penalty, and without ending the
        contract. The empresa moves to `suspended` immediately, everything already queued is
        cancelled in the same transaction, and the `mandato` step returns to `action_required`.

        **This is an atestación, not a write (ADR-0012 §4, ticket #465).** It is the act with
        the largest effect in the contract, so `empresas:write` is NOT enough: send it with
        `mandato:attest`, which is granted explicitly, is never implied by `empresas:write` and
        is never in the MCP vocabulary. A dashboard session may also call it — a session is the
        account holder in person. Anything else gets `403 insufficient_scope`, whose
        `error.details` carries `required_scope` and the `{method, url}` that mints the hosted
        revocation page for the representante legal
        (`POST /empresas/{empresa_id}/mandato/revocation_link`).

        It does NOT destroy the certificate — that is `DELETE .../certificate`, a separate
        revocation with its own deadline. And it does not undo what preceded it: the empresa's
        position is remembered, so a fresh acceptance returns it to where it was rather than
        restarting onboarding.

        The empresa stays READABLE while suspended (ticket #515): `GET /empresas/{id}`,
        `GET .../onboarding` and `GET .../mandato` all answer, `onboarding.blocked_on` names
        `mandato`, and `onboarding.pending_action` is the `mandato_revocado` handoff whose
        `link.mint` re-signs. `active` is untouched — a right the contract calls reversible is
        never implemented as a disappearance.
      security:
        - bearerAuth: [mandato:attest]
      responses:
        "204": { description: "Revoked. The empresa moves to `suspended`, and stays readable." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/mandato/revocation_link:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Empresas]
      operationId: createMandatoRevocationLink
      summary: Mint the hosted page where the representante legal revokes
      description: |
        Cláusula 7.1 belongs to the person who signed, and this is how you put them in front of
        it (ticket #465). The acceptance already hands them this address once, and the receipt
        e-mail carries it; this endpoint is for every later moment — the e-mail was lost, or
        never went out, or the customer is asking their integrator to help them stop.

        Holding the link is not holding the power: the page demands the representante legal's
        RUT **as recorded on the mandato they signed**, which this response deliberately does
        not echo. Minting revokes any previous revocation link, which is what single-use means
        when a link is reissued.

        The URL is returned EXACTLY ONCE — only a SHA-256 of the token is stored. It does not
        expire: the right it addresses does not either.

        `409 mandato_not_signed` when nothing is in force, because an address for a right that
        cannot be exercised is worse than a refusal that says so.
      security:
        - bearerAuth: [mandato:write]
      responses:
        "201":
          description: The link. Returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MandatoRevocationLink" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/resolucion/verify:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Empresas]
      operationId: verifyResolucionClaim
      summary: Verify a declared resolución with one production timbraje (Facturia operators only)
      description: |
        **OPERATOR ONLY — NOT CALLABLE WITH AN API KEY** (sii-api#581 D1, founder decision
        2026-09-23). Documented so an integrator knows what moves `resolucion.verification_status`
        and who moves it.

        A resolución stated on `POST`/`PATCH /v1/empresas` is a claim. It is verified by asking
        the PRODUCTION SII for ONE folio range of one declared factura tipo: the SII refuses a
        timbraje for a RUT it has not authorized for that tipo, so a granted range is the proof.
        That request is irreversible (a registered range must be used or annulled), so it is made
        only when a Facturia operator confirms it for this empresa. Nothing is requested
        implicitly, nothing during `POST /v1/empresas`, and nothing against the certification
        environment.

        Without `confirm: true` the call refuses with `400 confirmation_required` and names the
        exact request in the message and in `error.details.request`. With it, the confirmation
        (who, when, why) is recorded and ONE `folio_request` job is queued; the SII's answer then
        moves `resolucion.verification_status` from `pending_verification` to `verified` or
        `refused`. An attempt that gets no answer about the RUT (the SII's edge block, an unusable
        certificate) stays `pending_verification`.

        `409 resolucion_verification_not_allowed` names the blocking fact in
        `error.details.reason`: `sandbox`, `sfg_channel`, `no_claim`, `already_verified`,
        `refused`, `in_flight`, `range_registered`, `not_ready` (the empresa is not at
        `certificate_verified`) or `no_factura_tipo`.
      security:
        - operatorSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResolucionVerifyRequest" }
      responses:
        "202":
          description: Confirmed. The one production request is queued; the SII's answer lands on `resolucion`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResolucionVerification" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/counterparties:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: listCounterparties
      summary: Counterparties you have invoiced, with the address and contacts on file for each
      description: |
        A DERIVED list, not a stored one: it reads the receivers of the documents this empresa
        has already emitted (tipos 33, 34, 56 and 61) and joins whatever contacts and address
        book entries exist, so the screen opens as a checklist rather than a blank form. A
        counterparty that has an address or a contact but no document yet is included.

        The address fields come from the address book only. Where none is on file they are null
        and so is `source`, which on this list means "nothing recorded" rather than "nothing
        known" — the single-counterparty read is the one that falls back to what earlier
        documents said.

        `has_more` is always `false`. The window bounds the SCAN, not a page of results, and
        promising a cursor this shape cannot honour would be worse than saying so.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The counterparties, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Counterparty" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /contribuyentes/{rut}:
    parameters:
      - name: rut
        in: path
        required: true
        description: Any persona jurídica's RUT, canonical (`76000002-7`) or with dots, or without the hyphen.
        schema: { $ref: "#/components/schemas/Rut" }
    get:
      tags: [Empresas]
      operationId: getContribuyente
      summary: What the SII publishes about a company, by RUT, and a giro that fits a factura
      description: |
        The razón social and economic activities the SII publishes for a persona jurídica, and a
        suggested `giro` that fits the receptor of a factura (at most 40 characters, ISO-8859-1).
        For prefilling a customer before the first invoice to them.

        The source is the SII's public nómina of personas jurídicas
        (https://www.sii.cl/sobre_el_sii/nominapersonasjuridicas.html), which the SII republishes
        every few months and does not guarantee to be complete or current. `source.published_at`
        says which edition answered. Personas naturales are not in it: their RUTs answer 404, like
        a RUT that does not exist.

        A SUGGESTION, not a statement: nothing here is written into a document on its own. Show it
        to the person, and once confirmed, put it on file with
        `PATCH /v1/empresas/{id}/counterparties/{rut}` — that is what the next emission reads.

        `suggested_giro.basis` is `curated` when the text comes from Facturia's short wording for
        a common activity code (the official descriptions are usually longer than 40 characters,
        and a cut one would change what the factura says), `sii_description` when the official
        description fits as it stands. Null when neither applies: ask the person.

        Not empresa-scoped: the data is public and about a third party, so no `Facturia-Empresa`
        is needed. Limited to 120 requests a minute per account.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The contribuyente.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contribuyente" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: The nómina does not list this RUT (a persona natural, or no such company).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: The nómina has not been loaded yet (`contribuyentes_unavailable`). Retry after `Retry-After`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/counterparties/{counterparty_rut}:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - $ref: "#/components/parameters/CounterpartyRut"
    get:
      tags: [Empresas]
      operationId: getCounterparty
      summary: What you know about one counterparty, and whether you stated it or we inferred it
      description: |
        Your address book entry for this RUT — the razón social, giro and domicilio we will put
        on the next document to it.

        Two sources, in the order the emission resolver uses them. If you have put an address on
        file, that is the answer and `source` is `api`. If you have not, but you have already
        emitted a document to this RUT, the answer is read off the newest one and `source` says
        so — that is inference, not a statement, and it is the difference worth knowing before
        you spend a folio. If neither exists, this is a 404.

        The counterparty has no id: there is exactly one identity per RUT, so the RUT is the key.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The counterparty.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CounterpartyIdentity" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: Nothing is on file for this RUT and no document has ever been emitted to it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    patch:
      tags: [Empresas]
      operationId: updateCounterparty
      summary: Put a counterparty's razón social, giro and address on file
      description: |
        The SII will not file a factura whose receptor has no giro, dirección and comuna: it
        accepts the ENVELOPE and then refuses the DOCUMENT, and the folio is spent either way.
        Recording the address here answers that once instead of on every emission — and it is
        the only source that can answer for a counterparty you have never invoiced, which is
        exactly when the question bites.

        An upsert: the first call creates the entry, later ones edit it. There is no POST and no
        DELETE, because the entry has no lifecycle of its own.

        Partial. A field you do not send keeps its value; a field sent as `null` is cleared.
        Every value is held to the same SII schema limit a value sent on a document is held to —
        `giro` 40 characters, `address.street` 70, `address.comuna` and `address.city` 20,
        `legal_name` 100 — and to the same ISO-8859-1 rule. Over the limit is refused, never
        truncated: a silently shortened name of trade would change what every document to this
        counterparty says about who they are.

        No earlier document is required. Recording an address for a RUT you have not invoiced
        yet is the point.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CounterpartyUpdateRequest" }
      responses:
        "200":
          description: The stored counterparty.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CounterpartyIdentity" }
        "400":
          description: |
            A malformed RUT, a body naming no field at all, a value longer than the SII element
            it becomes (`field_too_long`) or one carrying a character ISO-8859-1 cannot represent
            (`unencodable_character`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/counterparties/{counterparty_rut}/contacts:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - $ref: "#/components/parameters/CounterpartyRut"
    get:
      tags: [Empresas]
      operationId: listCounterpartyContacts
      summary: The contacts on file for one counterparty
      description: The primary contact comes first — it is the only one that is notified.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The contacts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/CounterpartyContact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Empresas]
      operationId: createCounterpartyContact
      summary: Put a contacto responsable on file
      description: |
        A contact with no attestation is a legitimate state: it is stored, it is shown, and it
        is never messaged. Sending `opt_in: true` without `opt_in_method` is refused, because
        consent recorded without recording how it was obtained is not a record.

        The first contact for a counterparty becomes the primary unless you say otherwise.

        A number that has opted out cannot be re-added under a new id: that is `422
        contact_opted_out`, and it is what stops delete-and-recreate from undoing a STOP.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CounterpartyContactCreateRequest" }
      responses:
        "201":
          description: The contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CounterpartyContact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/counterparties/{counterparty_rut}/contacts/{contact_id}:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - $ref: "#/components/parameters/CounterpartyRut"
      - name: contact_id
        in: path
        required: true
        schema: { type: string }
        description: The `cnt_…` id.
    patch:
      tags: [Empresas]
      operationId: updateCounterpartyContact
      summary: Edit a contact, promote it, or record an opt-out
      description: |
        `primary: true` demotes whoever held it, in the same call — exactly one contact per
        counterparty may be primary and the database is what enforces it.

        `opted_out: true` records that the person asked to stop by some other channel.
        `opted_out: false` does not exist: an opted-out contact is frozen and cannot be edited,
        re-attested or re-enabled. Someone who asked to be left alone is not un-asked by a
        colleague fixing a typo in their name.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CounterpartyContactUpdateRequest" }
      responses:
        "200":
          description: The updated contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CounterpartyContact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Empresas]
      operationId: deleteCounterpartyContact
      summary: Remove a contact from the address book
      description: |
        204 whether or not it was there. The exception is an opted-out contact, which is kept:
        its row is precisely what makes re-adding the same number refusable.
      security:
        - bearerAuth: [empresas:write]
      responses:
        "204": { description: Removed. }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getOnboarding
      summary: Onboarding lifecycle, steps and blocking human actions
      description: Everything a wizard needs to render progress and tell the user what to do next.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: Onboarding state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Onboarding" }
              examples:
                default:
                  summary: "An SFG empresa, fully emitting — every step completed."
                  value:
                    object: onboarding
                    empresa_id: emp_01K2R9F3T8QW2X
                    mode: live
                    status: emitting
                    progress: 1
                    blocked_on: null
                    pending_action: null
                    estimated_ready_at: null
                    requirements: []
                    steps:
                    - key: mandato
                      label: Mandato firmado
                      status: completed
                      actor: customer
                      help: Nos autoriza a actuar ante el SII por tu empresa. Lo firma el representante legal y se puede revocar
                        cuando quieras.
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: own_record
                    - key: credential
                      label: Certificado o delegación configurada
                      status: completed
                      actor: customer
                      help: Para operar tu cuenta del Sistema de Facturación Gratuito necesitamos, o tu certificado digital
                        en custodia, o que registres el RUT de Facturia como usuario autorizado en tu Portal MIPYME.
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: sii_probe
                    - key: clave_centralizado
                      label: Clave del Sistema de Facturación Gratuito
                      status: completed
                      actor: customer
                      help: Es la clave con la que entras al Sistema de Facturación Gratuito en sii.cl. Se guarda cifrada
                        en la bóveda.
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: own_record
                    - key: usuario_autorizado
                      label: Usuario autorizado verificado
                      status: completed
                      actor: customer
                      help: Comprobamos que el certificado de Facturia puede actuar por tu empresa en el Portal MIPYME.
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: sii_probe
                    - key: emisor_verificado
                      label: Emisor autorizado verificado
                      status: completed
                      actor: facturia
                      help: Confirmamos que tu empresa ya está inscrita en el Sistema de Facturación Gratuito y autorizada
                        a emitir.
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: sii_probe
                    - key: puesta_en_vivo
                      label: Puesta en vivo
                      status: completed
                      actor: facturia
                      help: Nos toca a nosotros. Antes de que tu empresa emita su primer documento real hacemos una emisión
                        de prueba acompañada.
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: own_record
                    - key: emitting
                      label: Emitiendo
                      status: completed
                      actor: facturia
                      help: 'Todo listo: emitimos y recibimos tus documentos a través de tu cuenta del Sistema de Facturación
                        Gratuito.'
                      action_url: null
                      pending_action: null
                      completed_at: '2026-08-25T12:00:00Z'
                      evidence_source: own_record
                    certifications: []
                    iva_shape: null
                    updated_at: '2026-08-25T12:00:00Z'
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/links:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Empresas]
      operationId: createOnboardingLink
      summary: Mint a link to a hosted representante-legal page
      description: |
        The person who has to act is often not the person holding the account: the account
        belongs to the accountant, the certificate belongs to a socio, and the SII steps that
        need a person are the socio's. So the hosted pages are reached by a link, not by a
        login.

        `step` is any step of THIS empresa's checklist that needs a person - `actor: customer`
        (the step they perform) OR `stall_actor: customer` (a step of OURS that cannot finish
        until they decide). Which ones exist depends on its `certification_track`:
        `certificate`, `sii_postulacion`, `cumplimiento` and `habilitacion` on the mercado
        catalog; `credential`, `clave_centralizado` and `usuario_autorizado` on the SFG one. A
        step from the other catalog is refused with a `400` naming the ones this empresa does
        have. The mandato's link is single-use and comes from
        `POST /empresas/{empresa_id}/mandato` with an empty body instead.

        The `url` is returned exactly once - only its SHA-256 is stored. The link is reusable
        and lives thirty days, and re-minting revokes the previous one.

        The response also carries the step's `pending_action`, with `action_url` set to the URL
        just minted: the mint is the one moment both halves exist at once, so an agent gets the
        live address and the instructions to relay with it in a single call.
      security:
        - bearerAuth: [onboarding:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OnboardingLinkCreateRequest" }
      responses:
        "201":
          description: The link. `url` carries the token and is never shown again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnboardingLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/links/{step}:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - name: step
        in: path
        required: true
        schema: { type: string }
        description: |
          The step whose link is being revoked. Any step either catalog can mint a link for -
          the wider set on purpose, so a link minted under a checklist the empresa has since
          left can still be taken back.
    delete:
      tags: [Empresas]
      operationId: revokeOnboardingLink
      summary: Revoke a hosted representante-legal link
      description: |
        204 whether or not one existed. A revoked link answers exactly like one that never
        existed, so a token cannot be used to learn whether we ever asked a given company to
        sign something.
      security:
        - bearerAuth: [onboarding:write]
      responses:
        "204": { description: Revoked. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/go_live:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Empresas]
      operationId: authorizeGoLive
      summary: Ask the titular to approve the go-live, or state the decision on their behalf
      description: |
        The Declaracion de Cumplimiento is a decision about a month, not a progression. On
        declaring, the empresa "queda operando automaticamente en el sistema de facturacion
        electronica" and owes the Informacion Electronica de Compras y Ventas FROM THE MONTH OF
        THE DECLARACION, whether or not it emits anything. The SII's own page says the step may
        be postponed.

        So the month is the customer's to choose and a custody mandate does not carry it.

        **TWO FORMS, and the body is which one.**

        - **No body** mints an *enlace de aprobacion* for the titular and answers `201` with
          `approval_url` and `expires_at`. The human opens the page, sees the month and the
          IECV consequence in their own language, and decides. Single use, seven days, and the
          URL is returned exactly once - only its SHA-256 is stored. Scope `onboarding:write`.
        - **`{"authorized": true|false}`** states the decision on the titular's behalf: an
          *atestacion*, for an integrator that collected it in its own flow. Answers `200` with
          the authorization. Scope `onboarding:attest`.

        `empresas:write` implies `onboarding:write` — the no-body form — but NOT
        `onboarding:attest` (corrected 2026-09-03): an atestación is a human granting that power
        on purpose, every time, never a side effect of a wider grant a key already held. A key
        that used to send `{"authorized": true|false}` under `empresas:write` alone now gets
        `403 insufficient_scope` naming `onboarding:attest`.

        **This changed in 2026-09.** An empty body used to DEFAULT to `authorized: true`, so a
        caller who sent no body silently authorized a tax consequence. An omitted field is not a
        decision; if you were relying on that, send `{"authorized": true}` explicitly.

        `{"authorized": false}` withdraws while nothing has been filed. Once the declaracion is
        filed there is nothing left to withdraw and the call is refused with
        `409 cumplimiento_already_filed`.

        **Refusals arrive together.** The status and code are the first blocker's own - `422
        mandato_revoked`, for instance - and `error.details.blockers[]` lists every one, so you
        do not learn them one round trip at a time.

        **THE AUTHORIZATION IS FREE** (2026-09-04, #530). It used to be gated on the
        subscription, and only on this door: the hosted `cumplimiento` page for the
        representante legal answered `200` for the same unsubscribed empresa, so the rule lived
        in one channel and an agent that sent the human to the other one walked around it. It
        is a DECISION, not an emission — the titular saying «start my obligation this month» —
        and the gate stands where the value is: every act that spends a folio or leaves the
        building is still gated, so an authorized empresa that has not paid emits nothing. This
        operation no longer answers `402`.

        **There has to BE a decision (2026-09, #451).** Minting an approval link for an empresa
        that has not reached the Declaracion de Cumplimiento handed a human a page with nothing
        on it. Two refusals come first, before the blockers and before the mint:

        - `409 go_live_not_ready` - the mercado lane has not reached the decision.
          `error.details.blocked_on` names the step holding it up and `error.details.next` is
          the `{method, url}` that reports when it moves. Retriable: it WILL be ready.
        - `409 go_live_not_applicable` - this empresa is not certifying for mercado, so it has
          no Declaracion de Cumplimiento at all. What gates an SFG empresa's emission is
          `puesta_en_vivo`, which is ours. Not retriable.

        Neither applies to `{"authorized": false}`: a decision that exists can always be
        withdrawn, whatever the lane has done since.
      security:
        - bearerAuth: [onboarding:write]
        - bearerAuth: [onboarding:attest]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GoLiveRequest" }
      responses:
        "200":
          description: The authorization, as it now stands. Answered for the body form.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GoLiveAuthorization" }
        "201":
          description: The approval link, answered for an empty body. `approval_url` is shown once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GoLiveApproval" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/steps/{step}/recheck:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - name: step
        in: path
        required: true
        schema: { type: string }
        description: An onboarding step key from this empresa's own checklist.
    post:
      tags: [Empresas]
      operationId: recheckOnboardingStep
      summary: Tell us a human step was performed, and we go and look
      description: |
        The resume half of the `pending_action` handoff. A person has performed one of the acts
        Chilean law reserves to them (see `pending_action.performed_by`), and this asks us to
        find out whether the SII agrees.

        **This endpoint accepts a claim and produces no fact.** It takes no request body, writes
        no step status and moves no empresa. What it does is enqueue the producer that READS the
        SII, plus the tick that folds whatever that producer finds, and return the step's status
        as it stood the moment you called — read before the enqueue and returned unchanged. If
        you post a recheck on a step that is `action_required` and read `action_required` back,
        that is the endpoint working: the step advances when we see the fact, not when you say
        so. `verified` is on the response and it is always `false`.

        `evidence_source` says how (or whether) the step can be confirmed at all. `sii_probe`
        means we go and read the SII. `own_record` means the act lands in our own tables and we
        will see it. `no_producer` means this build genuinely cannot observe that step yet — the
        boleta `habilitacion` is the one — and `contact` names the door out:
        `POST .../onboarding/steps/{step}/attest` (#395), which is a DIFFERENT endpoint that
        writes a fact rather than merely recording a claim. Do not tell a user this call alone is
        watching a `no_producer` step; nothing is.

        `queued` and `already_queued` split the jobs this call created from the ones that were
        already scheduled for the same five-minute window. Both mean "we are about to look".

        For a sandbox empresa nothing is enqueued and `evidence_source` is `sandbox`: a test
        empresa's onboarding never opens a session against the SII, so there is nothing to
        re-read. Move its steps with the test helpers instead.
      security:
        - bearerAuth: [onboarding:write]
      responses:
        "202":
          description: The claim was accepted for verification. Nothing has been verified yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnboardingRecheck" }
              examples:
                default:
                  summary: "The claim was accepted; the step had not advanced yet."
                  value:
                    object: onboarding_recheck
                    empresa_id: emp_01K2R9F3T8QW2X
                    step: usuario_autorizado
                    status: action_required
                    accepted: true
                    verified: false
                    evidence_source: sii_probe
                    contact: null
                    queued:
                    - onboarding_step_probe
                    already_queued: []
                    message: Recibimos tu aviso. Vamos a revisar el Portal MIPYME y te avisamos apenas veamos el cambio.
                    pending_action: null
                    onboarding_url: /v1/empresas/emp_01K2R9F3T8QW2X/onboarding
                    requested_at: '2026-09-02T10:00:00Z'
        "404":
          $ref: "#/components/responses/NotFound"
        "429": { $ref: "#/components/responses/RateLimited" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/steps/{step}/attest:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - name: step
        in: path
        required: true
        schema: { type: string }
        description: An onboarding step key from this empresa's own checklist.
    post:
      tags: [Empresas]
      operationId: attestOnboardingStep
      summary: Record that a human performed a step this build cannot probe (#395)
      description: |
        The endpoint `recheck`'s own `contact` field points at when `evidence_source:
        'no_producer'`. Unlike `recheck` — which accepts a claim and produces no fact —
        **this endpoint writes one**: a `sii_onboarding_evidence` row the next onboarding tick
        reads as the fact that lets the step complete.

        Refused with `422 invalid_field` on any step whose `evidence_source` is not
        `no_producer` — attesting a step the SII probe or our own records already confirm would
        be a second, competing source of truth for a fact we can already verify. `habilitacion`
        (boleta habilitación at `certBolElectDteInternet`, an uncaptured SII SPA) is the only
        step this map names `no_producer` today.

        Requires `onboarding:attest` **itself**: never in the bootstrap default set, never
        implied by `empresas:write` alone (ADR-0012 §3-4). `attested_by` is required — who told
        you this happened.
      security:
        - bearerAuth: [onboarding:attest]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attested_by]
              properties:
                attested_by:
                  type: string
                  description: A name, or the representante legal's RUT. Who told you this happened.
                note: { type: string }
      responses:
        "202":
          description: The attestation was recorded. The step folds on the next tick.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnboardingAttestation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          $ref: "#/components/responses/NotFound"
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/steps/{step}/approve:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - name: step
        in: path
        required: true
        schema: { type: string }
        description: An onboarding step key from this empresa's own checklist.
    post:
      tags: [Empresas]
      operationId: approveOnboardingStep
      summary: "Record the titular's authorization for a step Facturia then performs (#452, #453)"
      description: |
        The sibling of `attest`, for the opposite situation. `attest` says «a person did
        something we cannot see»; this says «a person authorized something WE are about to do».

        Two acts in this pipeline are ours to perform once somebody chooses the moment. The
        postulacion is presented with the representante legal's own custodied certificate under
        mandato 4.2 a), and it costs an empresa on the Sistema de Facturacion Gratuito its
        "calidad de usuario activo" the instant the SII authorizes it. The boleta habilitacion
        opens a 24-hour SII clock. Neither is a decision a custody mandate carries, so each
        waits for a row in `sii_onboarding_step_approvals`.

        **`act` must be the one the step is currently asking for.** Read
        `steps[].pending_action.approval` on `GET .../onboarding`: it names the act, whose
        decision it is, the consequences to show first, and both button labels. A step asking
        for nothing answers `409 onboarding_approval_not_offered`; a mismatched `act` answers
        `400 invalid_field`. One step carries different decisions at different points of its
        life, and an approval recorded against the wrong one is consent that was never given.

        **A rejection is recorded, not ignored.** `decision: "reject"` writes a row exactly as
        an approval does, so the product can say "you left it for later" rather than "nobody
        answered". Executors read the LATEST row for the act and branch on `decision`.

        Requires `onboarding:attest` itself — never in the bootstrap default set, never implied
        by `empresas:write` (ADR-0012 §3-4). The canonical form of an approval is the hosted
        page (`POST /onboarding/steps/{step}/approve` with the link token); this is the
        integrator door for a decision collected in their own flow.
      security:
        - bearerAuth: [onboarding:attest]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [act, decision]
              properties:
                act:
                  type: string
                  description: The act being decided, exactly as `pending_action.approval.act` names it.
                decision:
                  type: string
                  enum: [approve, reject]
                note: { type: string }
      responses:
        "201":
          description: The decision was recorded. On `approve`, the fold tick is enqueued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OnboardingStepApproval" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/certification:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getCertification
      summary: The set de pruebas - what was uploaded, what is missing, what the SII said
      description: |
        One read, not four. A certification is a bounded thing - one set file, one input
        document, a handful of envios across at most three rounds - and splitting it would
        make the obvious screen four round trips whose answers can disagree.

        TWO FIELDS THAT LOOK ALIKE AND ARE OPPOSITE. `envios[].estado` is the ENVIO estado,
        where `SOK` means "envio recibido" - arrived, not judged. `envios[].verdict` is the
        SET verdict, where `SOK` means the set was APPROVED. Same three letters, opposite
        consequences, which is why they are two fields and never merged.

        `envios[].dry_run: true` means the envelope was built and NOT sent. `submittable` is
        `null` until a build has happened at all, which is not the same as `false`.

        `problems[].message` may be `null`: the sentences live in the build artifact beside
        the record, and when it cannot be read the response degrades to codes rather than
        failing. The code is always there.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The certification, as it now stands.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Certification" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/certification/set_file:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Empresas]
      operationId: uploadCertificationSetFile
      summary: Upload the set de pruebas the SII issued for this empresa
      description: |
        The SII issues the set `.txt` per taxpayer when it accepts the postulacion. Nothing
        downloads it for you; this is how the bytes get in.

        MULTIPART, NOT BASE64 IN JSON. The file is ISO-8859-1 and its accents are part of the
        document - `Cajon` and `Panuelo` are two of the items the SII reported as reparos when
        theirs were lost. Multipart hands us the bytes; a JSON string would already have been
        through somebody's idea of a text encoding by the time we saw it. The bytes are stored
        verbatim and never re-encoded. Limit: 1 MB.

        Bytes that are not a set de pruebas are refused with `422 set_file_unrecognized` and
        NOTHING is stored - a portal login page parsed as an empty set would report the
        empresa as having no casos, which is a different thing from having no set file.

        A set that IS recognised is stored even when parts of it were not understood; those
        appear in `problems[]`.

        This endpoint does not run a round, does not open a socket to the SII and does not
        spend a folio. The round is the engine's, on its own clock.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The `.txt` as the SII issued it. ISO-8859-1, at most 1 MB.
      responses:
        "201":
          description: The set as we read it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CertificationSetFile" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/certification/inputs:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Empresas]
      operationId: setCertificationInputs
      summary: Supply the facts the set de pruebas does not carry
      description: |
        The set states several things by implication and never spells them out: who issued
        each purchase in the libro de compras, what the CodRef-2 correction says, which
        factura invoiced a guia the set marks "facturada", the factor de proporcionalidad,
        the SII-issued folio de notificacion, and - for a boleta round - the abbreviated
        unidades and ten everyday boletas. Each one is a deliberate refusal in the engine:
        the round reports what it needs rather than inventing a value and spending folios on
        a document the SII will reject.

        THE MERGE IS PER TOP-LEVEL SLOT: absent keeps, `null` clears, present replaces WHOLE.
        Restating `proveedores` restates all of them - which is what a form submits and the
        only way to REMOVE one. A deep merge would leave the row you deleted in the next
        libro.

        NOTHING IS WRITTEN WHEN ANYTHING IS INVALID, not even the valid half: a partial write
        leaves the empresa in a state nobody asked for and a round that refuses for a reason
        you believe you already fixed. `400 certification_input_invalid` carries EVERY problem
        in `error.details.problems`, not the first. It may also refuse over a value you did
        not send, when the stored document your patch merges into is itself invalid.

        ONE SLOT IS PREFILLED: `libro_guias.folio_notificacion` defaults to 3, the number
        continuing the 1 and 2 the libro de ventas and the libro de compras carry.
        LibroGuia_v10.xsd makes the element mandatory where the IECV's is optional, so it
        cannot simply be omitted, and a round that goes out on the default says so in a
        notice. `libro_boleta_folio_notificacion` has no default and will not get one: it is
        an SII-issued number answering a specific notificacion and nothing can derive it.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CertificationInputs" }
      responses:
        "200":
          description: The inputs as they now stand on the empresa.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: certification_inputs }
                  empresa_id: { type: string }
                  inputs: { $ref: "#/components/schemas/CertificationInputs" }
                  updated:
                    type: array
                    items: { type: string }
                    description: The slots this call touched.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
  /empresas/{empresa_id}/onboarding/evidence:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - $ref: "#/components/parameters/Limit"
      - $ref: "#/components/parameters/Cursor"
      - name: step
        in: query
        required: false
        schema: { type: string }
        description: Only artifacts of this onboarding step.
      - name: kind
        in: query
        required: false
        schema: { type: string }
        description: Only artifacts of this kind.
    get:
      tags: [Empresas]
      operationId: listOnboardingEvidence
      summary: The rendicion de cuentas - what we did on the empresa's behalf
      description: |
        Clausula 4.4 of the mandato obliges us to render account of what we did on the
        customer's behalf. Every act the onboarding pipeline performs is kept as an artifact,
        and this reads them back, newest first.

        `retention_until` is on every row and is six years from the act - the Codigo
        Tributario's prescripcion extraordinaria plus its conservation duty, which is also the
        retention purpose under Ley 21.719. On revocation or traspaso the record freezes
        read-only and is offered as an export; it is never purged early.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The artifacts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/OnboardingEvidence" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/onboarding/evidence/export:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
      - name: step
        in: query
        required: false
        schema: { type: string }
        description: Only artifacts of this onboarding step.
      - name: kind
        in: query
        required: false
        schema: { type: string }
        description: Only artifacts of this kind.
    get:
      tags: [Empresas]
      operationId: exportOnboardingEvidence
      summary: The rendicion de cuentas as one CSV file
      description: |
        The same rows the list endpoint serves, complete in one response, as a file you can hand
        to a customer, an accountant or a lawyer. The retention ruling says that on revocation or
        traspaso we offer the customer a full export as the formal clausula 4.4 rendicion; this is
        that export, and it is available at any time, not only on the way out.

        RFC 4180, comma-separated, UTF-8 with NO byte-order mark, CRLF line endings. The columns
        are the JSON object's own fields in the same order - `id`, `empresa_id`, `step`,
        `tipo_dte`, `kind`, `captured_at`, `retention_until`, `sha256`, `bytes`, `content_type`,
        `detail` - so `storage_path` is absent here exactly as it is absent there. `detail`
        travels as its compact JSON, quoted.

        Paged internally and capped at 50,000 rows. When the cap is reached the response says so
        in `Facturia-Export-Complete: false` rather than serving a truncated file that looks
        whole; `step` and `kind` are how you narrow an export that hit it.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The rendicion, as a CSV attachment.
          headers:
            Content-Disposition:
              description: "`attachment; filename=\"rendicion-<rut>-<date>.csv\"`."
              schema: { type: string }
            Facturia-Export-Complete:
              description: "`false` when the 50,000-row cap truncated the export."
              schema: { type: string, enum: ["true", "false"] }
            Facturia-Export-Rows:
              description: How many rows the file carries.
              schema: { type: string }
          content:
            text/csv:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/automation_settings:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getAutomationSettings
      summary: Retrieve per-empresa automation settings
      description: "The empresa's automatic-emission toggles (`auto_characterize_purchases`, `auto_emit_46`, …), each defaulted where unset."
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: Automation settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationSettings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    patch:
      tags: [Empresas]
      operationId: updateAutomationSettings
      summary: Update per-empresa automation settings
      description: |
        Every automation defaults to OFF except `auto_request_folios`. `auto_emit_46` and
        `auto_file_f29` are opt-in per empresa and capped.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AutomationSettings" }
      responses:
        "200":
          description: Updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AutomationSettings" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /empresas/{empresa_id}/branding:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    get:
      tags: [Empresas]
      operationId: getBranding
      summary: Read logo, default print format and footer text
      description: |
        Every field is filled in from the platform defaults where the empresa has set nothing, so
        the object always describes what a PDF will actually look like rather than only what was
        overridden.
      security:
        - bearerAuth: [empresas:read]
      responses:
        "200":
          description: The empresa's branding.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Branding" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    patch:
      tags: [Empresas]
      operationId: updateBranding
      summary: Update logo, default print format and footer text
      description: "Update the panel and hosted-page branding fields (logo, colors, display name). An unknown field name is refused (`422 unknown_setting`), never silently ignored."
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Branding" }
      responses:
        "200":
          description: Updated branding.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Branding" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # ----------------------------------------------------------------- Folios

  /folios:
    get:
      tags: [Folios]
      operationId: listFolios
      summary: Folio availability per doc type
      description: "Folio ranges (CAF-backed) available to the empresa, with how many of each `tipo_dte` remain before a new request is needed."
      security:
        - bearerAuth: [empresas:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - name: tipo_dte
          in: query
          schema: { $ref: "#/components/schemas/TipoDte" }
      responses:
        "200":
          description: Folio ranges.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/FolioRange" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /folios/requests:
    post:
      tags: [Folios]
      operationId: requestFolios
      summary: Request a new CAF from the SII (async)
      description: The SII's timbraje is a scripted portal flow, not a webservice. Returns a job.
      security:
        - bearerAuth: [folios:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tipo_dte]
              properties:
                tipo_dte: { $ref: "#/components/schemas/TipoDte" }
                quantity:
                  type: integer
                  minimum: 1
                  description: Folios requested. Omit to let Facturia size it from consumption history.
      responses:
        "202":
          description: Job accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /cafs:
    get:
      tags: [Folios]
      operationId: listCafs
      summary: List CAFs held for this empresa
      description: "Every CAF (folio authorization) uploaded for the empresa, with its range, `tipo_dte` and how many folios remain."
      security:
        - bearerAuth: [empresas:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A list of CAFs.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Caf" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Folios]
      operationId: uploadCaf
      summary: Upload a CAF you already hold
      description: "Upload a CAF file obtained from the SII's own portal. Rejected if its range overlaps one already on file for the same `tipo_dte`."
      security:
        - bearerAuth: [folios:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [caf_xml]
              properties:
                caf_xml:
                  type: string
                  description: The raw `<AUTORIZACION>` XML as downloaded from the SII.
      responses:
        "201":
          description: CAF registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Caf" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /cafs/{caf_id}:
    parameters:
      - name: caf_id
        in: path
        required: true
        schema: { type: string, examples: ["caf_01K2P4V8QW2M"] }
    delete:
      tags: [Folios]
      operationId: deactivateCaf
      summary: Deactivate a CAF (history is preserved)
      description: "Deactivate a CAF (`active: false`). Folios already allocated from it are unaffected; the range simply stops offering new ones."
      security:
        - bearerAuth: [folios:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "204": { description: Deactivated. }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # -------------------------------------------------------------- Documents

  /documents:
    get:
      tags: [Documents]
      operationId: listDocuments
      summary: List emitted documents
      description: "List documents for the empresa, newest first, filterable by `tipo_dte`, `status` and date range."
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: expand
          in: query
          description: |
            Comma-separated, applied per returned page. On a LIST the vocabulary is narrower than
            on `GET /documents/{document_id}`: `events`, `references` and `received_document` only.
            `xml` and `pdf_base64` inline a whole artifact per row and are `400 invalid_field`
            here — fetch them from the single document, or from `/pdf` and `/xml` (§5.4).
          schema: { type: string, examples: ["events,received_document"] }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/DocumentStatus" }
        - name: tipo_dte
          in: query
          schema: { $ref: "#/components/schemas/TipoDte" }
        - name: folio
          in: query
          schema: { type: integer }
        - name: receptor_rut
          in: query
          schema: { $ref: "#/components/schemas/Rut" }
        - name: external_id
          in: query
          schema: { type: string }
        - name: issue_date[gte]
          in: query
          schema: { type: string, format: date }
        - name: issue_date[lte]
          in: query
          schema: { type: string, format: date }
        - name: created_at[gte]
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: A list of documents.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Documents]
      operationId: createDocument
      summary: Emit a DTE
      description: |
        Returns `201` with `status: queued` and **`folio: null`**. The folio arrives when the
        document reaches `sent`: it is allocated by the engine after the row exists, so no `201`
        can carry one. `null` there is a WAIT, not a failure and not a placeholder — observe it
        with `document.sent`, or by re-reading the document on the cadence
        `Facturia-Poll-After` gives you.

        The SII round-trip after that is a state machine you watch the same way. Everything not
        supplied is defaulted from the empresa profile (emisor block, giro, dirección), the
        receptor registry, and the tipo's tax rules.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DocumentCreateRequest" }
            examples:
              minimalBoleta:
                summary: Minimal boleta (two fields)
                value:
                  tipo_dte: 39
                  items:
                    - description: Servicio de mantención mensual
                      quantity: 1
                      unit_price: 25000
              minimalFactura:
                summary: Minimal factura afecta (three fields)
                value:
                  tipo_dte: 33
                  receptor:
                    rut: "76543212-K"
                    giro: Venta al por mayor de alimentos
                    address: { street: "Los Militares 5001, of. 302", comuna: Las Condes, city: Santiago }
                  items:
                    - description: Desarrollo a medida — agosto
                      quantity: 1
                      unit_price: 2400000
              fullFactura:
                summary: Full factura with references, discounts and asserted totals
                value:
                  tipo_dte: 33
                  external_id: order-8842
                  issue_date: "2026-08-14"
                  prices_include_tax: false
                  receptor:
                    rut: "76543212-K"
                    legal_name: Comercial Andina Ltda.
                    giro: Venta al por mayor de alimentos
                    address: { street: "Los Militares 5001, of. 302", comuna: Las Condes, city: Santiago }
                    email_document_to: [pagos@andina.cl]
                  items:
                    - description: Licencia plataforma — plan Enterprise
                      quantity: 12
                      unit: UN
                      unit_price: 180000
                      discount_percent: 5
                    - description: Servicio de capacitación
                      quantity: 1
                      unit_price: 300000
                  references:
                    - tipo_dte: 801
                      folio: "OC-99213"
                      date: "2026-08-01"
                      reason: Orden de compra
                  payment: { terms: credito, due_date: "2026-09-13", method: transferencia }
                  totals: { net: 2352000, iva: 446880, exempt: 0, total: 2798880 }
                  pdf: { cedible: true, public: true }
                  xml: { public: true }
                  metadata: { crm_deal: D-1182 }
              creditNote:
                summary: Nota de crédito correcting amounts
                value:
                  tipo_dte: 61
                  receptor:
                    rut: "76543212-K"
                    giro: Venta al por mayor de alimentos
                    address: { street: "Los Militares 5001, of. 302", comuna: Las Condes, city: Santiago }
                  references:
                    - tipo_dte: 33
                      folio: 412
                      date: "2026-08-14"
                      code: 3
                      reason: Corrige montos
                  items:
                    - description: Ajuste de precio
                      quantity: 1
                      unit_price: 120000
              facturaDeCompra46:
                summary: Factura de compra for a foreign digital service
                value:
                  tipo_dte: 46
                  receptor: { rut: "55555555-5", legal_name: Google Cloud EMEA Ltd, foreign: true }
                  items:
                    - description: Google Ads — julio 2026
                      quantity: 1
                      unit_price: 1000000
                  withholding: { iva: total }
              guiaDespacho:
                summary: Guía de despacho with a transport block
                value:
                  tipo_dte: 52
                  receptor:
                    rut: "76543212-K"
                    address: { street: Ruta 5 Sur km 12, comuna: San Bernardo, city: Santiago }
                  dispatch:
                    transfer_type: venta
                    carrier: { rut: "11111111-1", name: Transportes Sur, plate: KJHZ42, driver_rut: "13456789-9", driver_name: Ana Rojas }
                    requester_rut: "76543212-K"
                  items:
                    - description: Pallet de insumos
                      quantity: 4
                      unit_price: 120000
              sandboxForcedRejection:
                summary: Test mode — force a rejection
                value:
                  tipo_dte: 33
                  receptor:
                    rut: "76543212-K"
                    giro: Venta al por mayor de alimentos
                    address: { street: "Los Militares 5001, of. 302", comuna: Las Condes, city: Santiago }
                  items:
                    - description: Consultoría
                      quantity: 1
                      unit_price: 1200000
                  test: { outcome: rejected, reason_code: TED-2-510, settle_after_ms: 3000 }
      responses:
        "200":
          description: |
            The preview named by `preview_id` was ALREADY emitted, by this call's own earlier
            attempt or by a concurrent one. The original document, unchanged — nothing was created
            by this call and no second folio was spent (§10.2). Only reachable on a
            `{"preview_id": "prev_…"}` body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmittedDocument" }
        "201":
          description: |
            Document created and queued for transmission.

            Carries `Facturia-Poll-After` when the document is not terminal — which the `201`
            always is on the live lane, since it answers `status: "queued"`. It was attached only
            by `GET /v1/documents/{document_id}` until 2026-09-04, so an agent following «poll per
            `Facturia-Poll-After`» had to issue a first read purely to discover the number it was
            told to poll with (#522). Same value, same function, both doors.
          headers:
            Facturia-Poll-After:
              schema: { type: integer }
              description: |
                Seconds until the next useful read of this document. Absent when the document is
                already terminal (a sandbox emission is `accepted` immediately).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmittedDocument" }
              examples:
                default:
                  summary: "A minimal factura, just accepted for emission."
                  value:
                    id: doc_01K2R7Q3T5NX8B
                    object: document
                    mode: live
                    empresa:
                      id: emp_01K2R6ZC4P8QW1VN7T3MHDY9EK
                      rut: 77928532-4
                    external_id: null
                    tipo_dte: 33
                    folio: null
                    issue_date: '2026-09-02'
                    status: queued
                    receptor:
                      rut: 76543212-K
                      legal_name: Comercial Andina Ltda.
                    items:
                    - description: Desarrollo a medida — agosto
                      quantity: 1
                      unit_price: 2400000
                    totals:
                      net: 2400000
                      exempt: 0
                      iva: 456000
                      other_taxes: 0
                      total: 2856000
                    references: []
                    pdf_url: https://api.facturia.cl/v1/documents/doc_01K2R7Q3T5NX8B/pdf
                    xml_url: https://api.facturia.cl/v1/documents/doc_01K2R7Q3T5NX8B/xml
                    sii:
                      environment: produccion
                      track_id: null
                      estado: null
                      glosa: null
                      err_code: null
                      num_atencion: null
                      modified_by: []
                      received_at: null
                      last_checked_at: null
                    cesion:
                      cedible: null
                      sii_status_code: null
                      checked_at: null
                    rejection_reasons: []
                    annulled_by: null
                    metadata: {}
                    created_at: '2026-09-02T15:04:11Z'
                    accepted_at: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422":
          description: |
            Well-formed, and refused by a domain rule. The codes this operation answers, in the
            order the door checks them:

            - `empresa_not_emitting` — **live only**: this endpoint requires
              `onboarding.status = emitting`, and the empresa has not got there yet. In SANDBOX it
              is not checked at all: a test-mode empresa emits from the first minute, which is what
              makes the whole surface exercisable before the SII has issued anything.
              `error.details` is an `EmissionRefusalDetails` and carries the `pending_action` —
              who must act, where, and in what words — so the refusal itself is the handoff.
            - `empresa_not_active` — the empresa is `suspended` or `archived`.
            - `tipo_not_available_for_channel` — this `tipo_dte` is not emitted on this empresa's
              `emission_channel` (§6.2).
            - plus the payload rules: `totals_mismatch`, `receptor_incomplete`,
              `reference_required` and their siblings — see `GET /error_codes`.

            This list named folios_exhausted until 2026-09-04. There is no such code and there
            never was: running out of folios is not refused at this door at all — the range is
            drawn from AFTER the request is accepted, so it arrives as a `rejected` document or,
            on `POST /documents/previews`, as the non-blocking warning `no_folios_available`
            (#496 found it by cross-checking every code these sentences name against the published
            dictionary).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "default": { $ref: "#/components/responses/Default" }

  # `redocly lint`'s no-ambiguous-paths rule flags `/documents/previews/{preview_id}`
  # against `/documents/{document_id}/{pdf,xml,events,retry,send,cesion,cede,
  # credit_note,public_links}`, because a pure path-template comparison cannot
  # tell "previews" the literal segment from "previews" the value of
  # `{document_id}`. The router (Hono, `src/v1/router.ts`) resolves static
  # segments before parameterized ones regardless of registration order, so
  # `GET /documents/previews/xyz` always reaches `getDocumentPreview`, never
  # `getDocument` with `document_id: "previews"`. Renaming `/documents/previews/*`
  # to `/document_previews/*` would remove the ambiguity but is not additive
  # (§5.8) — left as a documented, benign warning (#499).
  /documents/previews:
    post:
      tags: [Documents]
      operationId: createDocumentPreview
      summary: Price and validate a document without emitting it
      description: |
        Runs the ENTIRE emission validator — the same refusals, in the same words — and then
        performs none of the emission. `folio` is `null` because none was taken; `folio_preview` is
        the folio that WOULD be used, read from the counter and not reserved.

        Stored for an hour and readable with `GET /documents/previews/{preview_id}`, which returns
        what was computed rather than re-pricing: totals that moved between being shown and being
        approved is the failure this flow exists to prevent.

        Emit it by sending `{"preview_id": "prev_…"}` to `POST /documents`. Emitting the same
        preview twice returns the ORIGINAL document with `200` and spends no second folio — a
        property enforced in the database, because an agent retrying a tool call is the ordinary
        case here and agents rarely send an `Idempotency-Key`.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        description: The same body `POST /documents` takes.
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DocumentCreateRequest" }
      responses:
        "201":
          description: The computed preview. Nothing was emitted and no folio was reserved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentPreview" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/previews/{preview_id}:
    parameters:
      - $ref: "#/components/parameters/PreviewId"
    get:
      tags: [Documents]
      operationId: getDocumentPreview
      summary: Retrieve a stored preview
      description: |
        Returns what was computed, never a re-computation. A preview that has been emitted stays
        readable with `emitted: true` and a `consumed_at`; an unconsumed one is gone an hour after
        it was created and answers `404`.
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The preview.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentPreview" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      tags: [Documents]
      operationId: getDocument
      summary: Retrieve a document
      description: "One document: its lifecycle `status`, totals, `public_links` if any are live, and `rejection_reasons` if the SII refused it."
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - name: expand
          in: query
          description: |
            Comma-separated. One or more of `xml`, `pdf_base64`, `events`, `references`,
            `received_document`. An unrecognised value is `400 invalid_field`.

            `received_document` is populated when this document was emitted in response to a
            supplier's — matched at emission by reference (tipo_dte, folio) plus emisor RUT =
            receptor RUT, within the same empresa, and only when that match is unique (§5.4).
          schema: { type: string, examples: ["xml,events"] }
      responses:
        "200":
          description: The document.
          headers:
            Facturia-Poll-After:
              description: Seconds until the next useful poll, from our own scheduler.
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
              examples:
                default:
                  summary: "The same document, after the SII accepted it."
                  value:
                    id: doc_01K2R7Q3T5NX8B
                    object: document
                    mode: live
                    empresa:
                      id: emp_01K2R6ZC4P8QW1VN7T3MHDY9EK
                      rut: 77928532-4
                    external_id: factura-0842
                    tipo_dte: 33
                    folio: 1024
                    issue_date: '2026-09-02'
                    status: accepted
                    receptor:
                      rut: 76543212-K
                      legal_name: Comercial Andina Ltda.
                    items:
                    - description: Desarrollo a medida — agosto
                      quantity: 1
                      unit_price: 2400000
                    totals:
                      net: 2400000
                      exempt: 0
                      iva: 456000
                      other_taxes: 0
                      total: 2856000
                    references: []
                    pdf_url: https://api.facturia.cl/v1/documents/doc_01K2R7Q3T5NX8B/pdf
                    xml_url: https://api.facturia.cl/v1/documents/doc_01K2R7Q3T5NX8B/xml
                    sii:
                      environment: produccion
                      track_id: '926194837'
                      estado: EPR
                      glosa: Envio Procesado
                      err_code: null
                      num_atencion: null
                      modified_by: []
                      received_at: '2026-09-02T15:06:40Z'
                      last_checked_at: '2026-09-02T15:07:02Z'
                    cesion:
                      cedible: null
                      sii_status_code: null
                      checked_at: null
                    rejection_reasons: []
                    annulled_by: null
                    metadata: {}
                    created_at: '2026-09-02T15:04:11Z'
                    accepted_at: '2026-09-02T15:07:02Z'
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/pdf:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      tags: [Documents]
      operationId: getDocumentPdf
      summary: Download the PDF
      description: "The document's rendered PDF (`?copy=original` or `cedible` where Ley 19.983 gives one). `409 artifact_not_ready` if the SII has not accepted the document yet — the representación impresa is rendered on acceptance, not before."
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - name: copy
          in: query
          description: |
            Which printed copy to serve. `cedible` is available for facturas — tipo_dte 33 and 34
            (Ley 19.983 art. 1; Res. Ex. SII N°14/2005 §1°; Manual de Muestras Impresas §1.4). Any
            other type returns `422 cedible_not_available`.

            `cedible` does not exist AT ALL on an `emission_channel=sfg` empresa, on any tipo: the
            SII signs and proxies its own PDF for that channel, and its bytes carry no
            acuse-de-recibo box or CEDIBLE legend to serve as one. `copy=cedible` there returns
            `422 cedible_not_available_for_channel`; `copy=original` is unaffected.

            The two copies are two separate files: the original carries no acuse-de-recibo box and
            no CEDIBLE legend, the cedible copy carries both. `both` was withdrawn in wave 9 — the
            muestras manual allows exactly one page per file (p.12) — and now returns
            `400 invalid_field`, as does any other unrecognised value.

            `format` (`carta` | `80mm`) was also withdrawn: no thermal layout exists and none was
            ever served. Supplying it returns `400 invalid_field`.

            **Omitting it** does not always mean `original`. The default is resolved in order: this
            document's own `pdf.cedible` (set at emission, §9.3) if it named one; else the empresa's
            `branding.cedible_by_default` (§7.6); else `original` — and never `cedible` on the SFG
            channel, regardless of either default. Send `?copy=` explicitly to override either
            default for one read.
          schema: { type: string, enum: [original, cedible] }
      responses:
        "200":
          description: The rendered PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "409": { $ref: "#/components/responses/Conflict" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/xml:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      tags: [Documents]
      operationId: getDocumentXml
      summary: Download the signed XML exactly as sent to the SII
      description: "The exact XML Facturia sent to the SII (or will send), for a caller that wants the wire bytes rather than the parsed object."
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The signed EnvioDTE / EnvioBOLETA XML (ISO-8859-1).
          content:
            application/xml:
              schema: { type: string }
        "409": { $ref: "#/components/responses/Conflict" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/events:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      tags: [Documents]
      operationId: listDocumentEvents
      summary: Lifecycle events for one document
      description: "The document's own event history — every `document.*` webhook Facturia published for it, in order, independent of whether delivery succeeded."
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: Events, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/DocumentEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/retry:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Documents]
      operationId: retryDocument
      summary: Retry a document in `failed`
      description: Reuses the already-assigned folio. Never creates a second document.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The document, back in `queued`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "409": { $ref: "#/components/responses/Conflict" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/send:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Documents]
      operationId: sendDocument
      summary: Email the document to the receptor
      description: |
        `202` when THIS call created the send; `200` with the SAME job when an identical one
        (same document, same addresses, same attachments) already exists — one email, one job id,
        mirroring §10.2's "emitting the same preview twice returns the original document". Both
        statuses carry the same body, so a client that only handles one of them is handling half
        the endpoint.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                to:
                  type: array
                  items: { type: string, format: email }
                attach:
                  type: array
                  items: { type: string, enum: [pdf, xml] }
                  default: [pdf, xml]
                  description: |
                    Always the ORIGINAL copy of the PDF — never cedible, whatever this document's
                    or the empresa's default says. §9.6: the cedible copy is an instrument for
                    assigning the debt, not the invoice you send a customer to be paid.
      responses:
        "200":
          description: An identical send already exists. The ORIGINAL job — no second email.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendJob" }
        "202":
          description: Queued for delivery.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendJob" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/cesion:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Documents]
      operationId: checkDocumentCesion
      summary: Ask the SII whether this factura is cedible
      description: |
        READ-ONLY (ticket #47). Queues a query against the SII's own registro
        (`WSREGISTRORECLAMODTE.consultarDocDteCedible`) and caches the answer on the document's
        `cesion` object. Nothing here cedes the invoice, builds an AEC, or writes to the Registro
        de Cesión de Créditos — that operation takes no action code and cannot change SII state.

        Under Ley 19.983 a factura becomes assignable when the receptor gives acuse de recibo, or
        when eight días corridos pass without a reclamo; one declared as paid al contado never
        does (Circular N°35 de 23.06.2017). Only the SII knows which of those applies, so this is
        a report, never a computation.

        `202` when this call queued the question, `200` when an identical one is already in
        flight. Poll `GET /documents/{document_id}` for the answer; there is no webhook.

        Accepts a `documents:read` key: the caller this exists for is a factoring integration,
        which should not need one that can issue invoices.

        In test mode the answer is simulated in-process against the empresa's simulated clock and
        no SII socket is opened — `24` until eight days after acceptance, `25` after.
      security:
        - bearerAuth: [documents:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: |
            A check against this same answer is already queued (or, in test mode, the simulated
            answer, already applied). The document, with its current `cesion`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "202":
          description: |
            Queued. The document, with `cesion` still showing the previously known answer — null
            throughout if this was the first check.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/cede:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Documents]
      operationId: cedeDocument
      summary: Cede this factura to a factoring company, registering it in the SII's RTC
      description: |
        THE WRITE (ticket #60), unlike `POST …/cesion` above (a read). Assembles an Archivo
        Electrónico de Cesión — the emisor's original signed DTE plus the terms of the transfer,
        signed by the cedente — and uploads it to the SII's Registro Electrónico de Transferencia
        de Crédito. A real, irreversible act.

        Works the same regardless of channel (ticket #60, ruled 2026-09-06): the AEC carries the
        emisor's original signed envelope verbatim, so it does not care whether the DTE was emitted
        through `emission_channel=sfg` or `emission_channel=mercado`. `cesion_channel_unsupported`,
        the code the old channel guard raised, is RETIRED from the catalogue.

        `202` with a `job` when this call queued a NEW attempt; `200` with the SAME job when one
        is already `queued`/`submitted`/`aec_generated` for this document (one attempt in flight
        at a time). `GET /documents/{document_id}` carries the newest attempt's outcome under
        `cesion.status`.

        A RECEIPT IS NOT A REGISTRATION (ticket #69). The furthest the upload itself can take an
        attempt is `submitted` — the SII answered «Envío recibido OK» and issued a TrackID. Its
        validation happens afterwards, and a rejection is notified by e-mail to the cedente hours
        later, so this API claims `confirmed` only when the registry's own
        `wsRPETCConsulta.getEstEnvio` answers `EOK` («Envío Aceptado») for that TrackID. Webhooks
        follow that exactly: `document.cesion_submitted` means «AEC recibido, anotación
        pendiente», `document.cesion_confirmed` is the one to settle money on, and
        `document.cesion_failed` covers a refusal, a fence, an answer nobody recognises, and a
        registration that could never be confirmed.

        `409 cesion_attempt_may_have_registered` when the newest attempt for this document had
        already put the signed AEC on the wire when it stopped (ticket #76). Whether it registered
        cannot be decided from here, so a second cesión is refused rather than risking the same
        credit being assigned twice. `error.details` carries this system's own last known registry
        answer for that attempt (`rtc_estado`, `rtc_track_id`) when one was ever recorded — reading
        it costs a column, not a new SII call; when neither was ever recorded the details say so.

        In test mode this settles in the same request (§4.1) — no socket opened, the attempt
        lands `simulated`, and BOTH `document.cesion_submitted` and `document.cesion_confirmed`
        fire, in that order, so the event a factoring integration acts on is exercisable in test.

        Live submission is owner-gated, and since the 2026-09-06 ruling (R6) also gated per
        empresa: the AEC assembly and the registry upload are built and checked against the SII's
        own schema — the first live upload (2026-09-03) was accepted by the registry with a real
        TrackID and then rejected at validation for a signature defect, since fixed. Opening it
        needs BOTH `ALLOW_LIVE_CESION_SUBMITS` (an operator's own shell, never a deploy) AND the
        ceding empresa's own connection reaching `onboarding_status=emitting`, `active`,
        `ambiente=palena` and not a sandbox connection. A live attempt missing either one is
        accepted and queued like any other and settles `failed` non-retryably, naming whichever
        condition is missing.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cesionario, ultimo_vencimiento]
              properties:
                cesionario:
                  type: object
                  required: [rut, razon_social, direccion, email]
                  properties:
                    rut: { $ref: "#/components/schemas/Rut" }
                    razon_social: { type: string }
                    direccion: { type: string, minLength: 5 }
                    email: { type: string, format: email }
                monto:
                  type: integer
                  description: CLP, in `[1, total]`. Defaults to the document's total.
                ultimo_vencimiento: { type: string, format: date }
                otras_condiciones: { type: string }
                email_deudor: { type: string, format: email }
                contacto:
                  type: object
                  properties:
                    nombre: { type: string }
                    fono: { type: string }
                    email: { type: string, format: email }
      responses:
        "200":
          description: A cesión attempt was already in flight for this document. The same job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CesionJob" }
        "202":
          description: Queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CesionJob" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/credit_note:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Documents]
      operationId: createCreditNoteForDocument
      summary: Emit a nota de crédito against this document
      description: |
        There is no "delete an invoice" in Chile. `code: 1` annuls the referenced document,
        `2` corrects text, `3` corrects amounts (supply `items` for the corrected values).
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, reason]
              properties:
                code:
                  type: integer
                  enum: [1, 2, 3]
                reason: { type: string, maxLength: 90 }
                items:
                  type: array
                  items: { $ref: "#/components/schemas/DocumentItem" }
      responses:
        "201":
          description: The nota de crédito (tipo 61).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /documents/{document_id}/public_links:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Documents]
      operationId: createDocumentPublicLinks
      summary: Create or rotate public links for this document's artifacts
      description: |
        Turns a public link on after the fact, or rotates an existing one (which invalidates the
        previous URL). A link exposes exactly one artifact of one document — never the empresa's
        other documents, never account data — and cannot be escalated into an API credential.

        `cedible` (ticket #18 wave 10) is NOT in the default set: unlike `pdf`/`xml` it does not
        exist for every document, so a caller who did not ask for it does not get one minted
        silently — mirroring `?copy=` on `GET …/pdf`, which defaults to `original` rather than
        guessing. Requesting it for a tipo outside 33/34 is `422 cedible_not_available`; requesting
        it on an `emission_channel=sfg` empresa, on any tipo, is `422 cedible_not_available_for_channel`
        (#48) — the SII proxies its own PDF for that channel and no cedible copy of it ever exists,
        so this refuses the mint rather than handing back a `.cedible` link that could only ever
        resolve to the non-cedible file.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [artifacts]
              properties:
                artifacts:
                  type: array
                  minItems: 1
                  items: { type: string, enum: [pdf, xml, cedible] }
                  default: [pdf, xml]
                expires_in_days:
                  type: [integer, "null"]
                  minimum: 1
                  maximum: 365
                  default: 90
                  description: Null means the link does not expire.
                expires_in_seconds:
                  type: [integer, "null"]
                  description: |
                    The same lifetime in seconds, clamped to 30s–365d. WINS when both units are
                    sent: days cannot express the ten-minute link an agent needs, and the shorter,
                    more specific unit is the safer reading of an ambiguous request.
      responses:
        "200":
          description: |
            The minted links. NOT the document: the secret half of each URL is stored hashed, so
            this response is the only place a working URL ever exists. Capture it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicLinks" }
        "409": { $ref: "#/components/responses/Conflict" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Documents]
      operationId: revokeDocumentPublicLinks
      summary: Revoke every public link for this document
      description: "Revoke every live public link minted for the document. Already-cached copies of the URL keep working until they expire; this only stops new fetches."
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "204": { description: Revoked. Existing URLs stop resolving immediately. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # ------------------------------------------------------ Received documents

  /received_documents:
    get:
      tags: [Received documents]
      operationId: listReceivedDocuments
      summary: List supplier DTEs received for this empresa
      description: "Supplier DTEs that arrived at the empresa's reception address, newest first, filterable by `tipo_dte` and acceptance `status`."
      security:
        - bearerAuth: [received:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: expand
          in: query
          description: |
            Accepts nothing on this list, and says so rather than ignoring it. `raw_xml` inlines
            the supplier's entire signed envelope, so it is available on
            `GET /received_documents/{received_document_id}` only; any value here is
            `400 invalid_field` (§5.4).
          schema: { type: string }
        - name: tipo_dte
          in: query
          schema: { $ref: "#/components/schemas/TipoDte" }
        - name: emisor_rut
          in: query
          schema: { $ref: "#/components/schemas/Rut" }
        - name: commercial_response_state
          in: query
          schema: { $ref: "#/components/schemas/CommercialResponseState" }
        - name: source
          in: query
          schema: { type: string, enum: [intercambio, rcv, manual] }
        - name: issue_date[gte]
          in: query
          schema: { type: string, format: date }
        - name: issue_date[lte]
          in: query
          schema: { type: string, format: date }
      responses:
        "200":
          description: A list of received documents.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ReceivedDocument" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /received_documents/{received_document_id}:
    parameters:
      - $ref: "#/components/parameters/ReceivedDocumentId"
    get:
      tags: [Received documents]
      operationId: getReceivedDocument
      summary: Retrieve a received document
      description: "One received DTE, including whatever commercial response (`accept`/`reject`/`acknowledge_receipt`) has been registered for it."
      security:
        - bearerAuth: [received:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - name: expand
          in: query
          description: "`raw_xml` returns the supplier's original XML inline, signature intact."
          schema: { type: string, examples: ["raw_xml"] }
      responses:
        "200":
          description: The received document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceivedDocument" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /received_documents/{received_document_id}/xml:
    parameters:
      - $ref: "#/components/parameters/ReceivedDocumentId"
    get:
      tags: [Received documents]
      operationId: getReceivedDocumentXml
      summary: Download the original supplier XML
      description: "The exact XML the SII delivered for this received document."
      security:
        - bearerAuth: [received:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The XML as received. This file is the legal artifact.
          content:
            application/xml:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /received_documents/{received_document_id}/pdf:
    parameters:
      - $ref: "#/components/parameters/ReceivedDocumentId"
    get:
      tags: [Received documents]
      operationId: getReceivedDocumentPdf
      summary: Render the received document as PDF
      description: "A rendered PDF of the received document, for display or archival outside the API."
      security:
        - bearerAuth: [received:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The rendered PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /received_documents/{received_document_id}/accept:
    parameters:
      - $ref: "#/components/parameters/ReceivedDocumentId"
    post:
      tags: [Received documents]
      operationId: acceptReceivedDocument
      summary: Accept the content (SII acción ACD)
      description: |
        Types 33/34/43 only, inside the 8-day window, once. Doing nothing is also a valid,
        legally meaningful choice: silence accepts on day 8.
      security:
        - bearerAuth: [received:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string, maxLength: 256 }
      responses:
        "201":
          description: The registered commercial response.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CommercialResponse" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /received_documents/{received_document_id}/reject:
    parameters:
      - $ref: "#/components/parameters/ReceivedDocumentId"
    post:
      tags: [Received documents]
      operationId: rejectReceivedDocument
      summary: Reclamar the document (RCD / RFP / RFT)
      description: Irreversible. There is no un-reclamar.
      security:
        - bearerAuth: [received:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  enum: [content, partial_missing, total_missing]
                  description: "content → RCD, partial_missing → RFP, total_missing → RFT."
                note: { type: string, maxLength: 256 }
      responses:
        "201":
          description: The registered commercial response.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CommercialResponse" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /received_documents/{received_document_id}/acknowledge_receipt:
    parameters:
      - $ref: "#/components/parameters/ReceivedDocumentId"
    post:
      tags: [Received documents]
      operationId: acknowledgeReceiptOfReceivedDocument
      summary: Otorgar recibo de mercaderías o servicios (SII acción ERM)
      description: "Register the `acuse de recibo` — receipt of the goods or service, distinct from `accept`/`reject`'s commercial acceptance — inside the statutory 8-day window."
      security:
        - bearerAuth: [received:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "201":
          description: The registered commercial response.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CommercialResponse" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # -------------------------------------------------------------------- Tax

  /tax/purchases:
    get:
      tags: [Tax]
      operationId: listPurchases
      summary: RCV — registro de compras
      description: "The purchases ledger (Registro de Compras) synced from the SII, filterable by period, with each entry's `estado_contable` and any `characterize` outcome."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Period"
        - name: estado_contable
          in: query
          schema: { $ref: "#/components/schemas/EstadoContable" }
        - name: tipo_dte
          in: query
          schema: { $ref: "#/components/schemas/TipoDte" }
        - name: counterparty_rut
          in: query
          schema: { $ref: "#/components/schemas/Rut" }
      responses:
        "200":
          description: RCV purchase entries, with sync freshness metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RcvEntryList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/sales:
    get:
      tags: [Tax]
      operationId: listSales
      summary: RCV — registro de ventas
      description: "The sales ledger (Registro de Ventas) synced from the SII, filterable by period."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Period"
        - name: tipo_dte
          in: query
          schema: { $ref: "#/components/schemas/TipoDte" }
        - name: counterparty_rut
          in: query
          schema: { $ref: "#/components/schemas/Rut" }
      responses:
        "200":
          description: RCV sales entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RcvEntryList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/summary:
    get:
      tags: [Tax]
      operationId: getTaxSummary
      summary: Aggregated RCV totals for a period (the F29 inputs)
      description: "Period totals across purchases and sales — the numbers an F29 proposal is built from, before any characterization is applied."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/PeriodRequired"
      responses:
        "200":
          description: Aggregation by direction and doc type.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaxSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/sync:
    post:
      tags: [Tax]
      operationId: syncTaxData
      summary: Force an RCV resync (async)
      description: |
        Trigger a fresh pull of the purchases and sales ledgers from the SII for the empresa, for ONE
        period. Returns a `job` (`type: rcv_sync`) to poll on `GET /jobs/{job_id}`; the ledger updates
        only once it succeeds, and `result` then carries what was read.

        **`period` defaults to the previous month** (America/Santiago), and the job always reports the
        period it read in `result.period`. The daily sync reads the last three months, and the first
        sweep after an empresa's first successful sync backfills the twelve months before that; anything
        older has NEVER been read unless somebody asked for it here — a `sync.last_synced_at` of `null`
        on `GET /tax/sales?period=…` is how you can tell. To reach further back, call this once per
        period; the bucket is 10 requests per minute per empresa.

        **Force means force.** A call while a sync for the same period is still queued or running
        attaches to that job rather than queueing a second one; a call after it has finished queues a
        fresh read, whatever the daily sync did this morning.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                period: { $ref: "#/components/schemas/Period" }
      responses:
        "202":
          description: Sync job accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/purchases/characterize:
    post:
      tags: [Tax]
      operationId: characterizePurchasesBulk
      summary: Caracterizar up to 500 purchase entries in one call
      description: "Characterize purchase-ledger entries in bulk (`entry_ids[]` plus the shared `estado_contable`/tax treatment), for the common case of clearing a whole period at once."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entries]
              properties:
                entries:
                  type: array
                  maxItems: 500
                  items:
                    allOf:
                      - type: object
                        required: [id]
                        properties:
                          id: { type: string }
                      - $ref: "#/components/schemas/Characterization"
      responses:
        "200":
          description: Per-entry results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: characterization_batch }
                  updated: { type: integer }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        error: { $ref: "#/components/schemas/ErrorBody" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/purchases/{entry_id}/characterize:
    parameters:
      - $ref: "#/components/parameters/RcvEntryId"
    post:
      tags: [Tax]
      operationId: characterizePurchase
      summary: Caracterizar one purchase entry (writes to the SII registry)
      description: "Characterize one purchase-ledger entry — its `estado_contable` and whether its IVA is recoverable — overriding the SII's own proposal where the empresa's own accounting disagrees."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Characterization" }
      responses:
        "200":
          description: The updated entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RcvEntry" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/purchases/{entry_id}/characterize/revert:
    parameters:
      - $ref: "#/components/parameters/RcvEntryId"
    post:
      tags: [Tax]
      operationId: revertPurchaseCharacterization
      summary: Undo the last settled caracterización on a purchase
      description: |
        Queues a NEW caracterización restoring the `previous_tipo_compra` recorded on the last
        settled action for this purchase, linked to it by `reverts`. It does not edit or delete the
        original: a write to a tax register that happened, happened, and its row is the evidence.

        An undo of a write to the SII is itself a write to the SII, so it passes the identical
        opt-in gate and the identical confirmation two-step. `409 characterization_nothing_to_revert`
        when no settled action exists, or when the last one recorded no previous value.

        Reverting to `no_recuperable` needs an `iva_no_recuperable_code`: the SII does not accept one
        without a reason, and the reason the earlier characterization used is only recoverable when a
        prior action on the same document recorded it.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                iva_no_recuperable_code:
                  type: integer
                  enum: [1, 2, 3, 4, 9]
                  description: Required only when the value being restored is `no_recuperable`.
                confirmation_id: { type: string }
      responses:
        "202":
          description: The queued revert action.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CharacterizationAction" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/purchases/{entry_id}/estado_contable:
    parameters:
      - $ref: "#/components/parameters/RcvEntryId"
    post:
      tags: [Tax]
      operationId: setEstadoContable
      summary: Move an entry between REGISTRO / PENDIENTE / NO_INCLUIR
      description: |
        Records a LOCAL bookkeeping estado for the purchase. It is written to
        `estado_contable_override` and never over `estado_contable`, which belongs to the SII's own
        register and is refreshed by every sync.

        The F29 propuesta ignores the override unless the empresa's
        `honor_estado_contable_override` automation setting is on; the response's
        `estado_contable_effective` says which value the propuesta will use. Every write is recorded
        with the key that made it and the SII value it did not replace.

        Changing an entry's caracterización at the SII is a different act, with a different
        endpoint (`POST /tax/purchases/{entry_id}/characterize`).
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [estado_contable]
              properties:
                estado_contable: { $ref: "#/components/schemas/EstadoContable" }
      responses:
        "200":
          description: The updated entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RcvEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/proposals:
    get:
      tags: [Tax]
      operationId: listF29Proposals
      summary: List the periods that have a computed propuesta
      description: |
        A SPEC ADDITION over §12.3, which documents only the per-period read. Without it a caller
        reconciling a year, or a dashboard drawing twelve months, has to guess which periods exist
        and probe each one — twelve requests, eleven of them `404`.

        The rows omit `validation` and `filing_id`: both are one query per proposal, and a caller
        scanning a year is asking "which months are computed", which `status` already answers. Read
        the single propuesta for those.
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A list of F29 propuestas, newest period first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/F29Proposal" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/proposals/{period}:
    parameters:
      - $ref: "#/components/parameters/PeriodPath"
    get:
      tags: [Tax]
      operationId: getF29Proposal
      summary: Computed F29 propuesta for a period
      description: |
        Computed by Facturia from the synced RCV plus the empresa's PPM and remanente parameters.
        The SII pre-fills its own F29 for its own portal but publishes no API for it.
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The proposal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/F29Proposal" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/proposals/{period}/upload_file:
    parameters:
      - $ref: "#/components/parameters/PeriodPath"
    get:
      tags: [Tax]
      operationId: getF29UploadFile
      summary: Download the SII-format upload .txt for this proposal
      description: "The SII's own upload-ready `.txt` for this F29 period, generated from the same proposal `GET /tax/f29/proposals/{period}` returns."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: Fixed-format upload file, ready to submit manually if you prefer.
          content:
            text/plain:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/proposals/{period}/public_links:
    parameters:
      - $ref: "#/components/parameters/PeriodPath"
    post:
      tags: [Tax]
      operationId: createF29ProposalPublicLinks
      summary: Mint a no-auth link to the propuesta's upload .txt
      description: |
        `upload_file_url` needs a bearer key, which makes it useless as an MCP `resource_link` —
        most MCP hosts cannot attach the caller's OAuth token when dereferencing one. This mints the
        same kind of signed, unguessable, expiring link a document's artifacts get (§9.6), serving
        the identical ISO-8859-1 bytes.

        `tax:read`, not `tax:write`: minting a link publishes nothing and changes no tax position.
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                artifacts:
                  type: array
                  items: { type: string, enum: [upload_file] }
                  default: [upload_file]
                expires_in_days:
                  type: [integer, "null"]
                  minimum: 1
                  maximum: 365
                  default: 90
                expires_in_seconds:
                  type: [integer, "null"]
                  description: Clamped to 30s–365d. Wins when both units are sent.
      responses:
        "200":
          description: The minted link. Capture it — the secret half is stored hashed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicLinks" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Tax]
      operationId: revokeF29ProposalPublicLinks
      summary: Revoke every public link for this propuesta
      description: "Revoke every live public link minted for this F29 proposal's upload file."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "204": { description: Revoked. Existing URLs stop resolving immediately. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/proposals/{period}/recompute:
    parameters:
      - $ref: "#/components/parameters/PeriodPath"
    post:
      tags: [Tax]
      operationId: recomputeF29Proposal
      summary: Recompute the proposal, optionally overriding codes
      description: "Rebuild the F29 proposal from the ledgers and any characterizations recorded since it was last computed. Does not file anything with the SII."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                overrides:
                  type: array
                  items:
                    type: object
                    required: [code, value]
                    properties:
                      code: { type: integer }
                      value: { type: integer }
                      reason: { type: string }
      responses:
        "200":
          description: The recomputed proposal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/F29Proposal" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/filings:
    get:
      tags: [Tax]
      operationId: listF29Filings
      summary: List F29 filings
      description: "F29 filings recorded for the empresa, newest first, each naming the period and the proposal it was filed from."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: period[gte]
          in: query
          schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
      responses:
        "200":
          description: A list of filings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/F29Filing" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Tax]
      operationId: createF29Filing
      summary: File the F29 for a period (async job)
      description: |
        There is no SII webservice for filing an F29. Facturia automates the SII "Upload" flow —
        a certificate-authenticated session submitting a fixed-format .txt — and models it as a job.
        `confirm_total_payable` must equal the proposal's figure.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [period, confirm_total_payable]
              properties:
                period: { $ref: "#/components/schemas/Period" }
                proposal_id: { type: string }
                confirm_total_payable:
                  type: integer
                  description: Guard against filing the wrong number. Must match the proposal.
      responses:
        "202":
          description: Filing accepted and queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/F29Filing" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/f29/filings/{filing_id}:
    parameters:
      - name: filing_id
        in: path
        required: true
        schema: { type: string, examples: ["f29fil_01K2RD9K3M8T"] }
    get:
      tags: [Tax]
      operationId: getF29Filing
      summary: Retrieve a filing
      description: "One F29 filing: the period, the filed totals, and the SII's own acknowledgment if one was captured."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The filing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/F29Filing" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/expenses:
    get:
      tags: [Tax]
      operationId: listExpenses
      summary: List ingested foreign-currency charges
      description: "Expense-ledger entries imported from a connected bank or accounting feed, filterable by period."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: A list of expenses.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Expense" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Tax]
      operationId: createExpense
      summary: Ingest a charge that may require a tipo-46 factura de compra
      description: "Import expense-ledger entries directly (outside a `connections` sync), for a source the empresa does not have an automatic feed for yet."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExpenseCreateRequest" }
      responses:
        "201":
          description: The stored expense; a suggestion may follow asynchronously.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Expense" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /connections:
    get:
      tags: [Connectors]
      operationId: listConnections
      summary: List connector connections for this empresa
      description: "Connectors (bank feeds, accounting integrations) linked to the empresa, with each one's last sync and status."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A list of connections.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Connection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Connectors]
      operationId: createConnection
      summary: Create a Fintoc bank/card connection
      description: |
        Returns `status: pending_link` with a `link_url`. Send the user through it once; from then
        on each sync keeps the foreign-currency movements, converts them into `expense` objects
        (`source: "fintoc"`, `fx_source: "card_rate"`) and lets the tipo-46 detector run.
        Credentials stay with Fintoc — Facturia stores a link token, never a bank password.
        `connection.linked` fires when the link completes.

        IN TEST MODE there is no link to complete, so the connection is born `active`, `link_url`
        is `null` and `connection.linked` fires with the `201` — §4.1's "complete in-process
        simulation", and the only way the connector surface is exercisable before a real bank is
        involved.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider: { type: string, enum: [fintoc] }
                account_types:
                  type: array
                  items: { type: string, enum: [checking, credit_card] }
                sync_frequency: { type: string, enum: [daily, weekly, manual], default: daily }
      responses:
        "201":
          description: The connection, awaiting the link flow.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /connections/{connection_id}:
    parameters:
      - $ref: "#/components/parameters/ConnectionId"
    get:
      tags: [Connectors]
      operationId: getConnection
      summary: Retrieve a connection
      description: "One connector's configuration and sync history, without its stored credentials."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The connection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Connectors]
      operationId: deleteConnection
      summary: Disconnect (revokes the link, stops ingestion)
      description: Expenses already created from this connection are kept.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "204": { description: Disconnected. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /connections/{connection_id}/sync:
    parameters:
      - $ref: "#/components/parameters/ConnectionId"
    post:
      tags: [Connectors]
      operationId: syncConnection
      summary: Force a movement sync (async)
      description: |
        `422 connection_not_linked` on a connection that is not `active`: `tax_bank_sync` only
        looks at linked rows, so queueing one would be a job that runs, does nothing, and reports
        `succeeded`.

        In TEST MODE the job comes back already `succeeded` — there is no bank to call and no
        worker to wait for, so a `queued` job would never move (§4.1).
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: |
            Sync job accepted — `queued` in live, already `succeeded` in test.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/statements:
    get:
      tags: [Connectors]
      operationId: listStatements
      summary: List uploaded cartolas
      description: "Bank or card statements imported for the empresa, the raw material `tax:write` imports feed into the expense ledger and its suggestions."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A list of statement imports.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Statement" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Connectors]
      operationId: uploadStatement
      summary: Upload a cartola (CSV/XLSX) to create expenses
      description: |
        Asynchronous. The returned job's `result` carries `rows_read`, `expenses_created`,
        `duplicates_skipped` and per-row `errors[]`. Deduplication is by
        (empresa, date, amount_original, currency, provider) plus `external_id` where present,
        so re-uploading an overlapping cartola is safe.

        `202` when this upload created the import; `200` with the ORIGINAL job when the exact same
        BYTES were uploaded before for this empresa (a `sha256` taken before anything touches
        storage) — no second storage object, no second parse. Same body either way.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                format:
                  type: string
                  enum: [auto, generic_csv, santander, bci, banco_chile, itau, scotiabank, xlsx]
                  default: auto
                default_currency:
                  type: string
                  description: Applied to rows whose currency column is absent.
      responses:
        "200":
          description: These exact bytes were already uploaded. The ORIGINAL import job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatementImportJob" }
        "202":
          description: Import job accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatementImportJob" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/suggestions:
    get:
      tags: [Tax]
      operationId: listSuggestions
      summary: List tax suggestions
      description: "Categorization suggestions Facturia derived from imported statements and expenses, awaiting a `confirm` or `dismiss`."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/SuggestionType" }
        - name: status
          in: query
          schema: { type: string, enum: [open, confirmed, auto_confirmed, dismissed, expired] }
        - $ref: "#/components/parameters/Period"
      responses:
        "200":
          description: A list of suggestions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Suggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/suggestions/{suggestion_id}:
    parameters:
      - $ref: "#/components/parameters/SuggestionId"
    get:
      tags: [Tax]
      operationId: getSuggestion
      summary: Retrieve a suggestion
      description: "One suggestion: what it proposes, and the evidence (matching entries, prior confirmations) it was derived from."
      security:
        - bearerAuth: [tax:read]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      responses:
        "200":
          description: The suggestion.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Suggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/suggestions/{suggestion_id}/confirm:
    parameters:
      - $ref: "#/components/parameters/SuggestionId"
    post:
      tags: [Tax]
      operationId: confirmSuggestion
      summary: Act on a suggestion (for `emit_46`, emits the document)
      description: "Accept a suggestion, applying its proposed categorization to the entries it names."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "201":
          description: The suggestion, with the resulting document when one was emitted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestion: { $ref: "#/components/schemas/Suggestion" }
                  document: { $ref: "#/components/schemas/Document" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /tax/suggestions/{suggestion_id}/dismiss:
    parameters:
      - $ref: "#/components/parameters/SuggestionId"
    post:
      tags: [Tax]
      operationId: dismissSuggestion
      summary: Dismiss a suggestion
      description: "Reject a suggestion without applying it. Facturia does not re-suggest the identical categorization for the same entries."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  enum: [not_digital_service, already_declared, not_applicable, other]
                note: { type: string }
      responses:
        "200":
          description: The dismissed suggestion.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Suggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # --------------------------------------------------------------- Webhooks

  /webhook_endpoints:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      description: "Webhook endpoints configured for the account, with their `enabled_events` and delivery health, secrets never included."
      security:
        - bearerAuth: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A list of endpoints.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Register a webhook endpoint
      description: The `secret` is returned once, on creation, and never again.
      security:
        - bearerAuth: [webhooks:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                description: { type: string }
                enabled_events:
                  type: array
                  items: { type: string }
                  description: Event types, or `["*"]` for everything including future types.
                  default: ["*"]
                empresas:
                  type: array
                  items: { $ref: "#/components/schemas/Rut" }
                  description: Restrict deliveries to these empresas. Defaults to all the key can see.
      responses:
        "201":
          description: The created endpoint, including its signing secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /webhook_endpoints/{webhook_endpoint_id}:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoint
      summary: Retrieve a webhook endpoint
      description: "One webhook endpoint's configuration and delivery health, without its signing secret."
      security:
        - bearerAuth: [webhooks:read]
      responses:
        "200":
          description: The endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Update a webhook endpoint
      description: "Update `url`, `enabled_events` or `description`. The signing secret is unaffected — use `rotate_secret` to change it."
      security:
        - bearerAuth: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                description: { type: string }
                enabled_events:
                  type: array
                  items: { type: string }
                empresas:
                  type: array
                  items: { $ref: "#/components/schemas/Rut" }
                status: { type: string, enum: [enabled, disabled] }
      responses:
        "200":
          description: The updated endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      description: "Delete the endpoint and destroy its signing secret (current and previous) in the vault; both are removed together so no orphaned secret survives the row."
      security:
        - bearerAuth: [webhooks:write]
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /webhook_endpoints/{webhook_endpoint_id}/ping:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
    post:
      tags: [Webhooks]
      operationId: pingWebhookEndpoint
      summary: Send a synthetic event to verify plumbing
      description: "Send a synthetic `ping` event to the endpoint's current URL, so an integrator can verify signature verification before relying on a real event."
      security:
        - bearerAuth: [webhooks:write]
      responses:
        "202":
          description: The delivery attempt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /webhook_endpoints/{webhook_endpoint_id}/rotate_secret:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: The previous secret keeps signing for 24 hours so you can deploy without downtime.
      security:
        - bearerAuth: [webhooks:write]
      responses:
        "200":
          description: The endpoint with its new secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /webhook_endpoints/{webhook_endpoint_id}/deliveries:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Inspect delivery attempts
      description: "Delivery attempts for this endpoint, newest first, with each attempt's response status and whether it will be retried."
      security:
        - bearerAuth: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { type: string, enum: [pending, succeeded, failed] }
        - name: event_id
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A list of deliveries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /webhook_endpoints/{webhook_endpoint_id}/deliveries/{delivery_id}/retry:
    parameters:
      - $ref: "#/components/parameters/WebhookEndpointId"
      - name: delivery_id
        in: path
        required: true
        schema: { type: string, examples: ["whd_01K2RG1P4M7X01"] }
    post:
      tags: [Webhooks]
      operationId: retryWebhookDelivery
      summary: Redeliver an event
      description: |
        Rate-limited at 20/min **per account**, not per credential (ticket #152) — a leaked key
        would otherwise turn this into a free outbound-request amplifier from Facturia's own IPs.
        There is also a hard ceiling of 20 total delivery attempts per `(endpoint, event)` pair,
        answered as `409 too_many_replays`.
      security:
        - bearerAuth: [webhooks:write]
      responses:
        "202":
          description: The new delivery attempt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "default": { $ref: "#/components/responses/Default" }

  /events:
    get:
      tags: [Webhooks]
      operationId: listEvents
      summary: Query the event ledger (retained 12 months)
      description: |
        Retention is 12 months — deliberately longer than a typical webhook ledger, so a whole F29
        tax cycle stays replayable. Webhook delivery is at-least-once and unordered; deduplicate
        on `id`.

        **Polling instead of webhooks.** If you cannot expose an HTTPS endpoint — a personal agent,
        a laptop, anything behind NAT — this endpoint is the supported substitute, and `?after=`
        is the parameter that makes it work:

            GET /v1/events?after=evt_01K2RG1P4M7X&limit=100

        `after` pages FORWARDS: oldest first, strictly newer than the id you name, `next_cursor`
        advancing away from it. The loop is `after = next_cursor ?? after`, processing `data` in
        order; it never re-reads a row and needs no set of seen ids. `after` accepts both an
        `evt_…` id (the last event you processed) and a `cur_…` value from `next_cursor`, so your
        stored checkpoint and the token you are handed are interchangeable. When `has_more` is
        `false`, `next_cursor` is `null` — keep the `id` of the last event you processed and send
        that next time.

        `cursor` is the opposite direction and the default: newest first, "older than this", which
        is what a dashboard reading a ledger wants. Sending both is `400` rather than a silent
        precedence — they name positions in opposite orders, and honouring either would skip rows
        for half the callers with nothing to tell them so.
      security:
        - bearerAuth: [webhooks:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: after
          in: query
          description: |
            Forward checkpoint: return events strictly NEWER than this one, oldest first. An
            `evt_…` id or a `cur_…` value from a previous forward page. Mutually exclusive with
            `cursor`.
          schema: { type: string, examples: ["evt_01K2RG1P4M7X"] }
        - name: type
          in: query
          schema: { type: string, examples: ["document.accepted"] }
        - name: empresa_rut
          in: query
          schema: { $ref: "#/components/schemas/Rut" }
        - name: created_at[gte]
          in: query
          schema: { type: string, format: date-time }
        - name: created_at[lte]
          in: query
          description: |
            Upper bound, inclusive. Honoured since the endpoint shipped and undeclared until now,
            which made "give me one closed month of the ledger" look impossible from the contract.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: A list of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /events/{event_id}:
    parameters:
      - name: event_id
        in: path
        required: true
        schema: { type: string, examples: ["evt_01K2RG1P4M7X"] }
    get:
      tags: [Webhooks]
      operationId: getEvent
      summary: Retrieve an event
      description: "One event from the account's own ledger — the same object a webhook delivery carries — for a caller reconciling by id rather than by subscription."
      security:
        - bearerAuth: [webhooks:read]
      responses:
        "200":
          description: The event.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # ------------------------------------------------------------------ Usage

  /usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Usage summary for a billing period
      description: "Current-period request and emission counts against the account's plan limits."
      security:
        - bearerAuth: [usage:read]
      parameters:
        - $ref: "#/components/parameters/Period"
        - name: group_by
          in: query
          schema: { type: string, enum: [empresa, tipo_dte, metric] }
      responses:
        "200":
          description: The usage summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /usage/records:
    get:
      tags: [Usage]
      operationId: listUsageRecords
      summary: The immutable usage ledger
      description: |
        One record per accepted document. A document is recorded when the SII accepts it —
        rejected, failed and sandbox documents never appear here. Corrections are compensating
        records with negative quantity, never updates. This is a report, not a meter: the plan
        is flat and unlimited, so nothing here is priced per document.
      security:
        - bearerAuth: [usage:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Period"
        - name: metric
          in: query
          schema: { $ref: "#/components/schemas/UsageMetric" }
        - name: empresa_rut
          in: query
          schema: { $ref: "#/components/schemas/Rut" }
      responses:
        "200":
          description: A list of usage records.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/UsageRecord" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  # ------------------------------------------------------------------- Jobs

  /jobs/{job_id}:
    parameters:
      - name: job_id
        in: path
        required: true
        schema: { type: string, examples: ["job_01K2R8B4V6WQ2X"] }
    get:
      tags: [Meta]
      operationId: getJob
      summary: Retrieve an async job (folio request, send, cesión, RCV sync, import)
      description: |
        One endpoint for every asynchronous operation the API starts. Read `type` first: it decides
        the shape of `result` and which scope may read the job (the scope that STARTS that kind of
        job, plus the read scope for the subject it acts on — a `documents:read` key may watch a
        send, and is refused a tax filing).

        `Facturia-Poll-After` says when to ask again, in seconds, and is ABSENT once `status` is
        `succeeded` or `failed`: there is nothing left to observe. Do not poll faster than it says —
        nothing on this surface can finish inside the queue's own 10-second claim interval.

        `mode` is on the object, and a job created in the other mode does not exist for this
        credential: the answer is `404`, not `403` (§4.2).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The job.
          headers:
            Facturia-Poll-After:
              description: |
                Seconds until the next useful poll. Absent on a terminal job. The cadence is per
                `type`: 10 s for `document_send` and `statement_import`, 15 s for `connection_sync`,
                30 s for `rcv_sync` and `cesion_submit`, 60 s for `folio_request` — the SII's
                timbraje is a scripted portal walk and asking sooner only earns a `429`.
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
              examples:
                default:
                  summary: "A folio_request job, succeeded."
                  value:
                    id: job_01K2R8B4V6WQ2X
                    object: job
                    type: folio_request
                    status: succeeded
                    mode: live
                    empresa_id: emp_01K2R6ZC4P8QW1VN7T3MHDY9EK
                    tipo_dte: 33
                    quantity: 50
                    result:
                      tipo_dte: 33
                      quantity: 50
                      caf_id: caf_01K2P4V8QW2M
                      from: 1001
                      to: 1050
                      note: Rango entregado por el SII.
                    created_at: '2026-09-01T14:02:11Z'
                    completed_at: '2026-09-01T14:02:47Z'
        "404": { $ref: "#/components/responses/NotFound" }
        "400":
          description: |
            `empresa_header_forbidden` — this operation is account-level (a job belongs to the
            key's account, not to one empresa); a `Facturia-Empresa` header on it is refused.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /mandato_terms:
    get:
      tags: [Meta]
      operationId: getMandatoTerms
      summary: The current mandato text and version
      description: |
        The text §7.3 tells you to present before posting an acceptance. Unauthenticated, for the
        same reason `GET /error_codes` is: the body is a constant with no tenant data in it.

        READ `status` FIRST. `draft` means the text has not finished attorney review and is not yet
        binding — do not present it to a representante legal as terms they are signing. It is
        derived from the document's own review banner, so it flips to `active` on the day the
        review lands rather than on the day someone remembers to update a constant.

        This endpoint hands over the words only. Recording an acceptance is
        `POST /empresas/{empresa_id}/mandato`.

        `annexes[]` carries the documents the contract's own header calls «parte integrante» —
        prices, personal data, the automation matrix — with their Markdown, because a page that
        asks somebody to accept a contract has to be able to show them all of it (ticket #519).
        `content_hash` still covers `text` alone: those are the bytes an acceptance records.

        `?version=` fetches a specific version. Omit it for the text in force — a caller who
        forgets it must never be handed a draft. Use it to read a draft of what comes next before
        it binds anyone, and to fetch the exact version an older acceptance recorded (the mandato
        receipt's `terms_url` does precisely that). An unknown value is `400 invalid_field`.
      security: []
      parameters:
        - name: version
          in: query
          required: false
          schema: { type: string }
          description: |
            A version from `available_versions`. Omitted means the current text.
      responses:
        "200":
          description: Mandato terms.
          content:
            application/json:
              schema:
                type: object
                required: [object, version, status, effective_date, content_hash, text]
                properties:
                  object: { type: string, const: mandato_terms }
                  version: { type: string, examples: ["0.1"] }
                  status:
                    type: string
                    enum: [draft, active]
                    description: >-
                      `draft` until attorney review completes. An integrator should refuse to
                      collect an acceptance against a draft.
                  effective_date: { type: string, format: date, examples: ["2026-08-14"] }
                  language: { type: string, examples: ["es-CL"] }
                  content_type: { type: string, examples: ["text/markdown"] }
                  content_hash:
                    type: string
                    description: >-
                      `sha256:…` over exactly the bytes of `text`. Pin it if you cache the terms —
                      it is how you tell that the version you showed someone is still the one we
                      serve.
                    examples: ["sha256:6447ef0de8961…"]
                  text: { type: string, description: "The mandato, in Markdown." }
                  current:
                    type: boolean
                    description: >-
                      Whether this is the version an acceptance recorded right now would be
                      recorded against. `false` for a draft of what comes next, which
                      `status: draft` alone could not distinguish from «in force, still under
                      review».
                  available_versions:
                    type: array
                    items: { type: string }
                    description: Every version this build can serve through `?version=`.
                  annexes:
                    type: array
                    description: >-
                      The annexes this version declares «parte integrante» of the contract,
                      in the order its header lists them.
                    items:
                      type: object
                      properties:
                        id: { type: string, examples: ["anexo-a"] }
                        title: { type: string, examples: ["Anexo A — Plan, Precios y Condiciones Comerciales"] }
                        file: { type: string, examples: ["anexo-a-precios.md"] }
                        markdown: { type: string, description: "The whole annex, in Markdown." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "default": { $ref: "#/components/responses/Default" }

  /error_codes:
    get:
      tags: [Meta]
      operationId: listErrorCodes
      summary: The machine-readable error dictionary (unauthenticated)
      description: |
        Every code this API can put in an `error.code`, plus the SII rejection codes that appear
        inside a document's `rejection_reasons[]`. Told apart by `type`: a `rejection` row is not a
        response and carries `http_status: null`.

        `retriable` is answered about the IDENTICAL request — `rate_limit_exceeded` is retriable
        because waiting fixes it, `totals_mismatch` is not. Build retry policy on that field rather
        than on the status code.

        No credential, and no rate limit: the body is a constant with no tenant data in it.
      security: []
      responses:
        "200":
          description: Every Facturia error code, its SII counterpart, and whether it is retriable.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ErrorCode" }
        "default": { $ref: "#/components/responses/Default" }

  # ----------------------------------------------------------- Test helpers

  /test_helpers/received_documents:
    post:
      tags: [Test helpers]
      operationId: injectReceivedDocument
      summary: Inject a supplier DTE as if it had arrived at the reception address
      description: "Sandbox only: inject a supplier DTE as if it had just arrived at the reception address, without waiting for a real counterparty to send one."
      security:
        - bearerAuth: [received:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tipo_dte, emisor]
              properties:
                tipo_dte: { $ref: "#/components/schemas/TipoDte" }
                folio: { type: integer }
                emisor: { $ref: "#/components/schemas/Party" }
                issue_date: { type: string, format: date }
                totals: { $ref: "#/components/schemas/Totals" }
                items:
                  type: array
                  items: { $ref: "#/components/schemas/DocumentItem" }
      responses:
        "201":
          description: The injected received document; `received_document.created` fires.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceivedDocument" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/rcv/seed:
    post:
      tags: [Test helpers]
      operationId: seedRcv
      summary: Seed RCV rows for a period so F29 propuestas have data
      description: "Sandbox only: seed the purchases/sales ledgers with synthetic entries, so RCV-dependent flows (characterization, F29) can be tested without a real SII sync."
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [period]
              properties:
                period: { $ref: "#/components/schemas/Period" }
                purchases: { type: integer, default: 10 }
                sales: { type: integer, default: 10 }
      responses:
        "201":
          description: |
            The rows were written, in this request. No job, and nothing to poll: the sandbox has no
            worker to wait for (§4.1), so a seed that answered `202` with a job would be a promise
            of an asynchrony that does not exist here.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RcvSeed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/clock:
    post:
      tags: [Test helpers]
      operationId: advanceClock
      summary: Advance this empresa's simulated clock
      description: Exercises the 8-day acuse window, folio expiry and F29 deadlines without waiting.
      security:
        - bearerAuth: [empresas:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                advance_days: { type: integer, minimum: 1, maximum: 400 }
                set_to: { type: string, format: date }
      responses:
        "200":
          description: The empresa's new simulated date.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: test_clock }
                  empresa_rut: { $ref: "#/components/schemas/Rut" }
                  now: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/empresas/{empresa_id}/onboarding:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Test helpers]
      operationId: setOnboardingState
      summary: Drive an empresa's onboarding to any state
      description: "Sandbox only: force the empresa's onboarding spine to a named `onboarding_status`, skipping every step between here and there."
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { $ref: "#/components/schemas/OnboardingStatus" }
                step: { type: string }
      responses:
        "200":
          description: The forced onboarding state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Onboarding" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/empresas/{empresa_id}/onboarding/steps:
    parameters:
      - $ref: "#/components/parameters/EmpresaId"
    post:
      tags: [Test helpers]
      operationId: setOnboardingStep
      summary: Force one onboarding step to a status
      description: |
        The finer of the two onboarding levers. `POST .../onboarding` moves the empresa's
        STATE; this moves ONE STEP, which is how you produce the parts of §7.4 a state
        cannot express: a step at `action_required` with its `action_url`, a certification
        gate that `failed` for one `tipo_dte` while the others passed, a step `skipped`
        because the RUT was already an authorized emisor.

        The four certification steps (`set_de_pruebas`, `simulacion`, `intercambio`,
        `muestras_impresas`) exist per document type. Omit `tipo_dte` to force every
        declared type at once; supply it to force one.

        Returns the full onboarding object and fires the same `empresa.onboarding.*`
        webhook the real machine would.
      security:
        - bearerAuth: [empresas:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [step, status]
              properties:
                step:
                  type: string
                  enum:
                    - mandato
                    - certificate
                    - sii_postulacion
                    - set_de_pruebas
                    - simulacion
                    - intercambio
                    - muestras_impresas
                    - cumplimiento
                    - resolucion
                    - habilitacion
                    - folios
                status:
                  type: string
                  enum: [pending, in_progress, action_required, completed, failed, skipped]
                tipo_dte:
                  $ref: "#/components/schemas/TipoDte"
                  description: |
                    Only meaningful on a certification step. Must be one of the empresa's
                    declared `doc_types`.
                blocked_reason:
                  type: object
                  description: Structured reason to attach to a `failed` or `action_required` step.
      responses:
        "200":
          description: The onboarding object after the forced step.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Onboarding" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/documents/{document_id}/estado:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      tags: [Test helpers]
      operationId: forceDocumentEstado
      summary: Force the raw SII estado of an already-emitted sandbox document
      description: |
        `test.outcome` on `POST /documents` forces an outcome AT EMISSION. This forces one
        AFTERWARDS, and it is the only deterministic way to exercise the transition a webhook
        consumer actually cares about: without it, `sent → accepted` means racing the sandbox's own
        ~1 s settle, and `stuck → accepted` is unreachable.

        IT WRITES THE ESTADO, NOT THE STATUS, on purpose. The public `status` is then derived by
        the ordinary mapping, so a test that drives a document to `DNK` proves the `reparo` mapping
        works rather than asserting it. The same `document.*` lifecycle event the real transition
        would fire is published, so a consumer under test receives exactly what production sends.

        Sandbox only, checked three times: the key's mode, the empresa's flag, and the DOCUMENT's
        own `is_sandbox` — which is stamped at insert and immutable, so a real document stays real
        even if the empresa flag were flipped afterwards.
      security:
        - bearerAuth: [documents:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [estado]
              properties:
                estado:
                  type: string
                  description: The SII estado to write, exactly as the poll executor would.
                  enum: [DOK, DNK, FAU, FNA, FAN, EMP, RCT, EPR, SOK, RPR, RLV]
      responses:
        "200":
          description: The document, with its derived status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/f29/proposals:
    post:
      tags: [Test helpers]
      operationId: seedF29Proposal
      summary: Inject a computed F29 propuesta
      description: |
        Computing a propuesta is a WORKER job, and the worker is not part of the sandbox's promise
        (§4.1: test mode is "a complete in-process simulation"). Without this helper the sandbox
        can seed an RCV and then wait for ever for a propuesta only a running worker produces,
        which makes the whole §12.3/§12.4 surface untestable in test mode.

        It writes the same row the real computation writes, so everything downstream — the
        serializer, the upload-file link, and above all the FILING GATE — reads an ordinary
        propuesta. The gate is not bypassed: it still reads `total_payable` and `observations` from
        this row, and it still demands a validation (see `/test_helpers/f29/validations`).

        Re-seeding the same period updates it rather than creating a second one.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [period]
              properties:
                period: { $ref: "#/components/schemas/Period" }
                summary:
                  type: object
                  description: |
                    Any omitted field defaults to 0, and `total_payable` to
                    `max(0, iva_debito - iva_credito) + iva_retenido + ppm`.
                  properties:
                    iva_debito: { type: integer }
                    iva_credito: { type: integer }
                    iva_retenido: { type: integer }
                    ppm: { type: integer }
                    total_payable: { type: integer }
                codes:
                  type: array
                  description: The F29 code rows. Omit for a plausible default set.
                  items:
                    type: object
                    properties:
                      code: { type: integer }
                      label: { type: string }
                      value: { type: integer }
                observations:
                  type: array
                  description: |
                    An observation with `severity: error` lands the propuesta `review_required`,
                    which is what the filing gate refuses.
                  items:
                    type: object
                    properties:
                      severity: { type: string, enum: [info, warning, error] }
                      message: { type: string }
      responses:
        "201":
          description: The seeded propuesta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: f29_proposal }
                  id: { type: string }
                  period: { $ref: "#/components/schemas/Period" }
                  status: { type: string, enum: [ready, blocked] }
                  summary:
                    type: object
                    properties:
                      total_payable: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

  /test_helpers/f29/validations:
    post:
      tags: [Test helpers]
      operationId: seedF29Validation
      summary: Force the cross-validation verdict for a period
      description: |
        The real cross-validation SKIPS the SII fetch for a sandbox empresa and always records
        `unavailable` — correctly, because a sandbox empresa has no SII to contrast against. But
        `unavailable` is not `validated`, so without this helper the filing gate refuses EVERY
        sandbox filing with `not_validated` and the rest of §12.4 can never be reached.

        Forcing the VERDICT rather than the filing is what keeps it honest: a test that drives a
        proposal to `validated` and then files proves the gate lets a validated proposal through,
        and one that leaves it `unavailable` proves the gate stops one that is not. The gate itself
        is untouched either way.
      security:
        - bearerAuth: [tax:write]
      parameters:
        - $ref: "#/components/parameters/EmpresaHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [period]
              properties:
                period: { $ref: "#/components/schemas/Period" }
                verdict:
                  type: string
                  enum: [validated, discrepant, unavailable]
                  default: validated
                material_count:
                  type: integer
                  minimum: 1
                  description: Only on `discrepant`; how many material differences to record.
      responses:
        "201":
          description: The recorded validation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: f29_validation }
                  period: { $ref: "#/components/schemas/Period" }
                  verdict: { type: string, enum: [validated, discrepant, unavailable] }
                  source: { type: string, enum: [sii_propuesta, none] }
                  material_count: { type: integer }
                  created_at: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "default": { $ref: "#/components/responses/Default" }

# ---------------------------------------------------------------- Webhooks
# OpenAPI 3.1 native webhook declarations. Every payload is an `Event` whose
# `data.object` is the full current resource.

webhooks:

  # THIS BLOCK IS THE EVENT CATALOG, and `test/event-producers.test.ts` section C
  # asserts it — in both directions — against `ALL_EVENT_TYPES` in
  # `src/v1/events/types.ts`. It listed 40 of the 53 for a long time, omitting
  # thirteen that all had producers, and a consumer generating from the contract
  # had no way to learn they existed: `WebhookEndpoint.enabled_events` is a free
  # string array, so it tells no lies and teaches nothing either.
  #
  # The grouping is the CATALOG's own, not a convenience: a family exists here
  # when its members are one state machine. `document.emailed` is deliberately
  # not in `document.lifecycle` — §13.5 defines `document.sent` as "transmitted
  # to the SII", and a subscriber acting on it is acting on the SII holding the
  # document, never on a mailbox receiving it.

  document.lifecycle:
    post:
      operationId: onDocumentLifecycle
      summary: document.created | .sent | .accepted | .reparo | .rejected | .failed | .annulled
      description: |
        Fired on every transition of an emitted document. `document.accepted` (SII estado DOK)
        is the one most integrations act on, and the one that produces a usage record.

        `queued` earns no event on purpose: `document.created` already said the document exists,
        and every pre-transmission step reads as `queued` from outside.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
        - $ref: "#/components/parameters/DeliveryIdHeader"
        - $ref: "#/components/parameters/DeliveryAttemptHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - document.created
                        - document.sent
                        - document.accepted
                        - document.reparo
                        - document.rejected
                        - document.failed
                        - document.annulled
                    data:
                      type: object
                      properties:
                        object: { $ref: "#/components/schemas/Document" }
      responses:
        "200":
          description: Acknowledge within 10 seconds. Anything else is a failed delivery and is retried.

  document.delivery:
    post:
      operationId: onDocumentDelivery
      summary: document.emailed | .email_failed
      description: |
        The document EMAIL, which is a different state machine from the SII round trip and is kept
        apart in the vocabulary for that reason. `document.email_failed` fires on PERMANENT failure
        only — a delivery the job will retry is not news, and a webhook per attempt is noise nobody
        can act on.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - document.emailed
                        - document.email_failed
      responses:
        "200": { description: Acknowledged. }

  document.cesion:
    post:
      operationId: onDocumentCesion
      summary: document.cesion_submitted | .cesion_confirmed | .cesion_failed
      description: |
        Ley 19.983 cesión (ticket #60, corrected by #69). **Settle money on
        `document.cesion_confirmed` and on nothing else.** `document.cesion_submitted` means «AEC
        generado, registro pendiente» — the portal proved the AEC FILE exists, which is not
        registration in the Registro de Cesión de Créditos. `document.cesion_failed` covers a
        refusal, a fence, an unrecognised page, and a registration that could never be confirmed.

        Not an overload of `document.failed`, which means the SII rejected the EMISSION: a
        subscriber acting on that name must never be told an unrelated act went wrong.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - document.cesion_submitted
                        - document.cesion_confirmed
                        - document.cesion_failed
      responses:
        "200": { description: Acknowledged. }

  received_document.lifecycle:
    post:
      operationId: onReceivedDocumentLifecycle
      summary: received_document.created | .deadline_approaching | .accepted | .rejected | .receipt_acknowledged
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - received_document.created
                        - received_document.deadline_approaching
                        - received_document.accepted
                        - received_document.rejected
                        - received_document.receipt_acknowledged
                    data:
                      type: object
                      properties:
                        object: { $ref: "#/components/schemas/ReceivedDocument" }
      responses:
        "200": { description: Acknowledged. }

  reception.operations:
    post:
      operationId: onReceptionOperations
      summary: reception.acuse_sent | .acuse_failed | .quarantined
      description: |
        The Intercambio side of reception, named `reception.*` rather than `received_document.*`
        precisely so nobody can subscribe to "the supplier's invoice was acknowledged" believing
        they subscribed to acceptance. The acuse is the RespuestaEnvio — the technical
        acknowledgement the standard requires — and the SII's own words are that it «no implica la
        aceptación comercial». Every acuse payload carries `is_commercial_acceptance: false`.

        `reception.quarantined` is mail that reached the reception address and could not be routed
        to any empresa. Operational: there is no document to point at, which is the problem being
        reported.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - reception.acuse_sent
                        - reception.acuse_failed
                        - reception.quarantined
      responses:
        "200": { description: Acknowledged. }

  empresa.lifecycle:
    post:
      operationId: onEmpresaLifecycle
      summary: empresa.* | certificate.* | folios.* | connection.* | api_key.* | webhook_endpoint.disabled
      description: |
        Everything that happens to an empresa or to the account's own machinery rather than to a
        document. `empresa.onboarding.action_required` is the one an agent driving a guided
        onboarding acts on: it carries the same `pending_action` the refusal and
        `GET /empresas/{id}/onboarding` carry. `empresa.onboarding.step.completed` fires once per
        step that just reached `completed`, with `data.detail.step` naming it — so a subscriber
        does not have to diff `steps[]` between two reads to notice one step finishing.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - empresa.created
                        - empresa.updated
                        - empresa.onboarding.updated
                        - empresa.onboarding.step.completed
                        - empresa.onboarding.action_required
                        - empresa.onboarding.completed
                        - empresa.suspended
                        - certificate.uploaded
                        - certificate.revoked
                        - certificate.expiring
                        - certificate.expired
                        - folios.low
                        - folios.exhausted
                        - folios.replenished
                        - webhook_endpoint.disabled
                        - connection.linked
                        - connection.sync_succeeded
                        - connection.sync_failed
                        - connection.requires_relink
                        - api_key.created
                        - api_key.rolled
                        - api_key.revoked
      responses:
        "200": { description: Acknowledged. }

  tax.lifecycle:
    post:
      operationId: onTaxLifecycle
      summary: tax.rcv.synced | tax.f29.* | tax.suggestion.* | tax.statement.imported
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - tax.rcv.synced
                        - tax.f29.proposal.ready
                        - tax.f29.proposal.updated
                        - tax.f29.review_required
                        - tax.f29.filing.submitted
                        - tax.f29.filing.succeeded
                        - tax.f29.filing.rejected
                        - tax.f29.filing.failed
                        - tax.suggestion.created
                        - tax.suggestion.auto_confirmed
                        - tax.statement.imported
      responses:
        "200": { description: Acknowledged. }

  ping:
    post:
      operationId: onPing
      summary: ping
      description: |
        Synthetic, from `POST /webhook_endpoints/{id}/ping`. Never billed and never stored against
        an empresa — it exists so an endpoint can be proven reachable and its signature verified
        before a real event depends on it.
      parameters:
        - $ref: "#/components/parameters/SignatureHeader"
        - $ref: "#/components/parameters/EventIdHeader"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  properties:
                    type:
                      type: string
                      enum:
                        - ping
      responses:
        "200": { description: Acknowledged. }

# -------------------------------------------------------------- Components

components:

  securitySchemes:
    operatorSecret:
      type: apiKey
      in: header
      name: X-Admin-Secret
      description: |
        The Facturia OPERATOR's secret — never an API key, root keys included. Declared by exactly
        one operation, `POST /empresas/{empresa_id}/resolucion/verify`: the per-empresa
        confirmation of an irreversible production timbraje (sii-api#581 D1). Customers do not
        hold it and cannot call that operation.
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer sk_live_…` or `sk_test_…`. The prefix decides the mode:
        test keys drive a fully mocked sandbox that never contacts the SII.
        Keys carry a scope set and an empresa allowlist; a key allowlisted to exactly one
        empresa is an "empresa token" and makes the `Facturia-Empresa` header optional.
        Root keys (`sk_live_root_…` / `sk_test_root_…`) carry only `keys:write` and exist solely
        to mint, roll and revoke other keys — a minted key can never exceed its minter's
        empresas, scopes or mode.

        EVERY OPERATION BELOW DECLARES THE SCOPE IT ACTUALLY CHECKS, as an OAuth-style scope on
        this scheme (`security: [{bearerAuth: [documents:write]}]`), not only in the `Scope` enum.
        `[]` means "any authenticated key, no particular scope" (`GET /account`, `GET /api_keys`,
        `GET /jobs/{job_id}` — whose real gate is per job `type`, spelled out in its own
        description); two entries under the same operation are alternatives, exactly like
        `requireAnyScope` in the code (`POST …/mandato`, `POST …/onboarding/go_live`) — either
        scope is sufficient, neither is required alongside the other. This is not strict OpenAPI
        3.1: the spec reserves non-empty scope arrays for `oauth2`/`openIdConnect` schemes, and a
        pedantic validator may warn on an `http`/`bearer` scheme carrying one. It was chosen over a
        parallel `x-facturia-scopes` extension because every codegen target this contract is known
        to feed (`v1-published-contracts.test.ts`, the docs-site's own renderer) reads `security`
        already for "does this need a credential" and simply ignores scopes it does not model,
        while an `x-` extension is invisible to that same tooling unless it is taught to look —
        the whole reason ADR-0011 exists is that undiscoverable is indistinguishable from absent.
        `test/agent-contract.test.ts` §H asserts each declared scope equals the literal `requireScope`
        (or `requireAnyScope`) call at that route.

  parameters:

    EmpresaHeader:
      name: Facturia-Empresa
      in: header
      required: false
      description: |
        Target empresa: canonical RUT (`77928532-4`) or empresa id (`emp_…`).
        Required on every empresa-scoped call unless the key is an empresa token. This header is
        the ONLY mechanism: there is no `?empresa=` query parameter and no `empresa` body field.
        Omitted where required → `400 empresa_required`; outside the allowlist → `403 empresa_not_allowed`.
      schema: { type: string, examples: ["77928532-4"] }

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated key, unique per logical operation, retained 24h and scoped to
        (account, empresa, endpoint). Replays return the original response with
        `Facturia-Idempotent-Replay: true`. Same key + different body → `409`.
      schema: { type: string, format: uuid }

    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }

    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `next_cursor`.
      schema: { type: string }

    Period:
      name: period
      in: query
      description: Tax period, `YYYY-MM`, America/Santiago.
      schema: { $ref: "#/components/schemas/Period" }

    PeriodRequired:
      name: period
      in: query
      required: true
      schema: { $ref: "#/components/schemas/Period" }

    PeriodPath:
      name: period
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Period" }

    EmpresaId:
      name: empresa_id
      in: path
      required: true
      schema: { type: string, examples: ["emp_01K2R6ZC4P8QW1VN7T3MHDY9EK"] }

    CounterpartyRut:
      name: counterparty_rut
      in: path
      required: true
      description: |
        The counterparty's RUT, canonical (`76000002-7`). It is the key of the relationship and
        of the address book entry — a counterparty has no id of its own, because there is exactly
        one of each per RUT.
      schema: { $ref: "#/components/schemas/Rut" }

    KeyId:
      name: key_id
      in: path
      required: true
      schema: { type: string, examples: ["key_01K2QWB7T4N9"] }

    ConnectionId:
      name: connection_id
      in: path
      required: true
      schema: { type: string, examples: ["con_01K2RJ7B2W4M"] }

    DocumentId:
      name: document_id
      in: path
      required: true
      schema: { type: string, examples: ["doc_01K2R7Q4XW9M3B8ZC5YHTVJD6N"] }

    PreviewId:
      name: preview_id
      in: path
      required: true
      schema: { type: string, examples: ["prev_01K2R9M3D8V5QW7N"] }

    ReceivedDocumentId:
      name: received_document_id
      in: path
      required: true
      schema: { type: string, examples: ["rdoc_01K2R9M3D8V5QW7N"] }

    RcvEntryId:
      name: entry_id
      in: path
      required: true
      schema: { type: string, examples: ["rcv_01K2RB7Q2M4X"] }

    SuggestionId:
      name: suggestion_id
      in: path
      required: true
      schema: { type: string, examples: ["sug_01K2RE2X7Q9V"] }

    WebhookEndpointId:
      name: webhook_endpoint_id
      in: path
      required: true
      schema: { type: string, examples: ["whe_01K2RF5N8Q2W"] }

    SignatureHeader:
      name: Facturia-Signature
      in: header
      required: true
      description: |
        `t=<unix>,v1=<hex>` — HMAC-SHA256 over `"{t}.{raw_body}"` keyed by the endpoint's
        `whsec_` secret. Verify against raw bytes; reject anything older than 300s.
        During rotation two `v1=` values appear; accept if any matches.
      schema: { type: string }

    EventIdHeader:
      name: Facturia-Event-Id
      in: header
      required: true
      description: Deduplicate on this. Delivery is at-least-once and unordered.
      schema: { type: string }

    DeliveryIdHeader:
      name: Facturia-Delivery-Id
      in: header
      required: true
      description: |
        `whd_…` — this ATTEMPT's id, as opposed to `Facturia-Event-Id`, which is the event's. Two
        deliveries of the same event share an event id and differ here. It is the id
        `POST /webhook_endpoints/{id}/deliveries/{delivery_id}/retry` takes.
      schema: { type: string, examples: ["whd_01K2RG1P4M7X01"] }

    DeliveryAttemptHeader:
      name: Facturia-Delivery-Attempt
      in: header
      required: false
      schema: { type: integer, minimum: 1 }

  responses:

    Default:
      description: |
        An error not otherwise enumerated for this operation, in the same §5.5 envelope as every
        other refusal. Branch on `error.code`, never on the HTTP status alone or on `error.message`
        — a code this operation has not been seen to throw yet is still safer to route on than a
        string meant for a human.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    BadRequest:
      description: Malformed or missing input.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: Missing, malformed or revoked key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Forbidden:
      description: Valid key, not permitted — scope, empresa allowlist, or mode.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    NotFound:
      description: No such object under this key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Conflict:
      description: State conflict — idempotency reuse, duplicate external_id, artifact not ready.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unprocessable:
      description: Well-formed but violates a domain rule — folios exhausted, acuse window closed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    PaymentRequired:
      description: |
        `subscription_required` - this live act needs an active subscription for the EMPRESA
        the request names, and that empresa has none (enforced). The subscription is per
        empresa: another empresa on the same account is unaffected, and `error.details`
        carries `empresa`, `empresa_rut`, `empresa_name`, `account_plan`,
        `subscription_status`, `grace_until`, `blocked_by`, a `next` handoff and a
        `checkout_url` pointing at that empresa.
        Or `plan_limit_reached` - a plan quota is exhausted (declared, not yet enforced).
        Test mode is never gated on either.

        **ELIGIBILITY IS EVALUATED FIRST** (2026-09-04, #475). This refusal now arrives ONLY
        when the empresa is otherwise able to emit: while its checklist is unfinished, its
        `puesta en vivo` pending or the empresa suspended, the act is refused by
        `422 empresa_not_emitting` with the checklist handoff instead. So this `402` and
        `Empresa.capabilities.blocked_by` can no longer name two different next actions for one
        state — which they did, the object saying «sign the mandato» while the door said «pay».
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After:
          schema: { type: integer }
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  schemas:

    # ---- primitives

    Rut:
      type: string
      pattern: "^[0-9]{1,8}-[0-9K]$"
      description: Canonical RUT — no dots, one hyphen, uppercase K.
      examples: ["77928532-4", "76543212-K"]

    Period:
      type: string
      pattern: "^\\d{4}-\\d{2}$"
      description: Tax period `YYYY-MM` (America/Santiago).
      examples: ["2026-07"]

    Clp:
      type: integer
      description: Amount in CLP. Integer pesos, never decimals or strings.

    Mode:
      type: string
      enum: [live, test]

    TipoDte:
      type: integer
      enum: [33, 34, 39, 41, 46, 52, 56, 61]
      description: |
        33 factura afecta · 34 factura exenta · 39 boleta afecta · 41 boleta exenta ·
        46 factura de compra · 52 guía de despacho · 56 nota de débito · 61 nota de crédito.
        Boletas (39/41) travel a different SII transport; that is invisible through this API.

    Address:
      # 70/20/20 is the SII's own cap on all three uses of this object: DirRecep/CmnaRecep/
      # CiudadRecep on a receptor, DirOrigen/CmnaOrigen/CiudadOrigen on an emisor, and
      # DirDest/CmnaDest/CiudadDest on a dispatch.destination. The document door refuses an
      # over-long one with `400 field_too_long`; on an empresa the engine refuses it at the
      # first emission instead.
      type: object
      properties:
        street: { type: string, maxLength: 70 }
        comuna: { type: string, maxLength: 20 }
        city: { type: string, maxLength: 20 }

    EmpresaAddress:
      type: object
      description: |
        The address AS READ BACK off an empresa (#496). Structurally `Address`, with every member
        nullable, because `POST /v1/empresas` requires none of the three and stores `null` for
        whatever was omitted — which is the ordinary create. The REQUEST shape stays `Address`
        with non-null strings: what you may send and what you may read back are different
        statements, and collapsing them would either weaken the request or keep lying about the
        response. Same family of defect as #473's `contact_email`.
      properties:
        street: { type: [string, "null"], maxLength: 70 }
        comuna: { type: [string, "null"], maxLength: 20 }
        city: { type: [string, "null"], maxLength: 20 }

    Party:
      type: object
      required: [rut]
      description: |
        A counterparty — a company OR a natural person. There is no company flag, and none is
        needed: a natural person (`rut` + `legal_name`, no `giro`, no `address`) is a valid
        receptor for a boleta (39/41). Its name is written to `<RznSocRecep>` and to the timbre's
        `<RSR>`, its RUT to `<RUTRecep>`/`<RR>` — the boleta a patient presents to an Isapre or a
        seguro complementario. Without `legal_name` the boleta names the RUT beside «Sin RUT»;
        without a receptor it is issued to the anonymous consumer `66666666-6`.

        A nota de crédito (61) to a natural person is valid too, but it is a factura-family
        document: like every 61 it needs `giro`, `address.street` and `address.comuna`, even when
        it annuls a boleta. `POST /v1/documents/{id}/credit_note` copies the receptor from the
        original boleta, so store those three once with
        `PATCH /v1/empresas/{id}/counterparties/{rut}` before annulling, or the note is refused
        with `400 receptor_incomplete` before any folio is spent.
      properties:
        rut: { $ref: "#/components/schemas/Rut" }
        legal_name: { type: string, maxLength: 100 }
        giro: { type: string }
        address: { $ref: "#/components/schemas/Address" }
        contact: { type: string }
        foreign:
          type: boolean
          description: Foreign counterparty. Use the SII generic RUT `55555555-5` when unregistered.
        email_document_to:
          type: array
          items: { type: string, format: email }

    Totals:
      type: object
      properties:
        net: { $ref: "#/components/schemas/Clp" }
        exempt: { $ref: "#/components/schemas/Clp" }
        iva: { $ref: "#/components/schemas/Clp" }
        other_taxes:
          $ref: "#/components/schemas/Clp"
          description: >-
            Zero on every document except a tipo 46 with `withholding`, where it carries the
            retención NEGATED (e.g. `-1891` when the IVA retained is `1891`) — the only sign
            that keeps `total = net + exempt + iva + other_taxes` true for every document,
            withheld or not.
        total: { $ref: "#/components/schemas/Clp" }

    List:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        url: { type: string }
        data: { type: array, items: {} }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    OptinMethod:
      type: [string, "null"]
      enum: [en_persona, telefono, email, escrito, otro, null]
      description: |
        How the person actually agreed to be messaged. `en_persona` = in person or at the point
        of sale; `telefono` = by phone, recorded by whoever took the call; `email` = by email or
        a web form; `escrito` = on paper, signed; `otro` = something else, said plainly rather
        than forced into the nearest fit.

        WhatsApp's policy is that holding a number is not consent — "an affirmative, disclosed
        opt-in action is required" — so an attestation without a method is refused with
        `400 optin_method_required`.

    CounterpartyContactOptin:
      type: object
      description: |
        The attestation, as an event rather than a flag. Either all of `attested_at`,
        `attested_by` and `method` are present, or none of them is.
      properties:
        attested: { type: boolean }
        attested_at: { type: [string, "null"], format: date-time }
        attested_by:
          type: [string, "null"]
          description: Who recorded it — the dashboard session or the API key that posted it.
        method: { $ref: "#/components/schemas/OptinMethod" }
        first_message_at:
          type: [string, "null"]
          format: date-time
          description: |
            When the first notification went to this contact. The first message is the one that
            carries the opt-out line; `null` means none has been sent yet.

    CounterpartyContact:
      type: object
      properties:
        object: { type: string, const: counterparty_contact }
        id: { type: string, examples: ["cnt_01K2R7Q4XW9M3B8ZC5YHTVJD6N"] }
        empresa_id: { type: string }
        counterparty_rut: { $ref: "#/components/schemas/Rut" }
        counterparty_name: { type: [string, "null"] }
        name:
          type: string
          description: The person. A notification addressed to a company is addressed to nobody.
        phone:
          type: string
          description: E.164, with the leading `+`.
          examples: ["+56912345678"]
        primary:
          type: boolean
          description: |
            At most one per counterparty, enforced by a partial unique index. In v1 ONLY the
            primary is notified; the rest are an address book you can promote from.
        opt_in: { $ref: "#/components/schemas/CounterpartyContactOptin" }
        opted_out_at:
          type: [string, "null"]
          format: date-time
          description: |
            Set when the number replied STOP or you recorded an opt-out. One-way: an opted-out
            contact is frozen, and re-adding the same number is `422 contact_opted_out`.
        opted_out_source: { type: [string, "null"], enum: [inbound_stop, manual, null] }
        notifiable:
          type: boolean
          description: |
            `primary && attested && !opted_out` — the same predicate the emission hook applies,
            computed here so a screen and the engine cannot disagree about it.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CounterpartyAddress:
      type: object
      description: |
        The receptor's domicilio, as the SII's own elements: street is DirRecep, comuna is
        CmnaRecep, city is CiudadRecep. Always present as an object, even when every part of it
        is null, so a client never has to guard the parent before reading a part.
      properties:
        street: { type: [string, "null"] }
        comuna: { type: [string, "null"] }
        city:
          type: [string, "null"]
          description: |
            Optional to the SII's schema in every branch, and filled whenever it is known. The
            free SII portal lane validates it anyway, so an empresa on that lane is asked for it.

    Contribuyente:
      type: object
      description: |
        What the SII's public nómina of personas jurídicas says about one RUT, plus a giro short
        enough for `GiroRecep`. A suggestion for a person to confirm, never written into a
        document on its own.
      properties:
        object: { type: string, const: contribuyente }
        rut: { $ref: "#/components/schemas/Rut" }
        legal_name:
          type: [string, "null"]
          description: The current razón social, as the SII spells it. Null in the rare case the nómina lists activities without a current name.
        activities:
          type: array
          description: Economic activities in the SII's order. Empty for a company with none registered.
          items: { $ref: "#/components/schemas/ContribuyenteActivity" }
        suggested_giro:
          type: [object, "null"]
          properties:
            giro: { type: string, maxLength: 40, examples: ["Compraventa de vehículos"] }
            basis: { type: string, enum: [curated, sii_description] }
            activity_code: { type: string, examples: ["451002"] }
        source:
          type: object
          properties:
            published_at:
              type: [string, "null"]
              format: date-time
              description: When the SII published the edition that answered.
            loaded_at:
              type: [string, "null"]
              format: date-time
              description: When Facturia loaded it.

    ContribuyenteActivity:
      type: object
      properties:
        code: { type: string, description: "The SII's six-digit activity code.", examples: ["451002"] }
        description: { type: [string, "null"], description: The SII's official description. }
        vat_affected: { type: boolean, description: Whether the activity is afecta a IVA. }
        tax_category: { type: [integer, "null"], description: "Categoría tributaria (1 or 2)." }

    CounterpartyIdentity:
      type: object
      description: |
        What an empresa knows about one counterparty: the razón social, giro and domicilio that
        go into the receptor block of the next document to that RUT.

        No id. There is exactly one identity per (empresa, counterparty RUT), so the RUT is the
        key — and `source` says whether a person put it there or we read it off an earlier
        document.
      properties:
        object: { type: string, const: counterparty }
        rut: { $ref: "#/components/schemas/Rut" }
        legal_name: { type: [string, "null"] }
        giro: { type: [string, "null"] }
        address: { $ref: "#/components/schemas/CounterpartyAddress" }
        source:
          type: [string, "null"]
          description: |
            Where this answer came from. "api" is an address you recorded on purpose;
            "prior_document" is read off the newest document you emitted to this RUT, which is
            inference and is treated as the weaker source everywhere. Null on a list entry means
            nothing is on file — the list does not do the earlier-document fallback, the
            single-counterparty read does.
          examples: ["api"]
        updated_at:
          type: [string, "null"]
          format: date-time
          description: When the entry was last written, or when the document it was read off was created.

    Counterparty:
      type: object
      description: |
        One counterparty of this empresa: the address book entry, if there is one, joined onto
        the documents already emitted to that RUT and the contacts on file. The LIST of
        counterparties is still derived and nothing copies a receiver into a row — only a
        deliberate PATCH does that.
      properties:
        object: { type: string, const: counterparty }
        empresa_id: { type: string }
        rut: { $ref: "#/components/schemas/Rut" }
        name:
          type: [string, "null"]
          description: |
            The razón social to display — the address book's, else the spelling put on a contact,
            else whatever the newest document carried. Identical to legal_name, and kept because
            it is what this list has always published.
        legal_name: { type: [string, "null"] }
        giro: { type: [string, "null"] }
        address: { $ref: "#/components/schemas/CounterpartyAddress" }
        source:
          type: [string, "null"]
          description: |
            "api" when an address is on file for this RUT, null when none is. This list never
            answers "prior_document" — reading that would mean opening the payload of every
            document in the scan window; the single-counterparty read is where it lives.
        updated_at: { type: [string, "null"], format: date-time }
        document_count:
          type: integer
          description: How many notifiable documents (33/34/56/61) were seen in the scan window.
        contacts:
          type: array
          items: { $ref: "#/components/schemas/CounterpartyContact" }
        notifiable:
          type: boolean
          description: Whether any contact on file would actually be messaged.

    CounterpartyUpdateRequest:
      type: object
      description: |
        Partial. A field you do not send keeps its value; a field sent as null is cleared, which
        is how an address recorded by mistake is removed.
      properties:
        legal_name:
          type: [string, "null"]
          description: RznSocRecep, at most 100 characters.
        giro:
          type: [string, "null"]
          description: |
            GiroRecep, at most 40 characters — half of what the emisor's own giro is allowed, and
            the cap that bites most often. Refused over the limit, never truncated.
        address: { $ref: "#/components/schemas/CounterpartyAddress" }

    CounterpartyContactCreateRequest:
      type: object
      required: [name, phone]
      properties:
        name: { type: string }
        phone:
          type: string
          description: E.164. Anything that cannot be brought to that form is `400 invalid_phone`.
          examples: ["+56912345678"]
        counterparty_name:
          type: [string, "null"]
          description: Overrides the razón social the documents carried, for display only.
        primary:
          type: boolean
          description: Defaults to true for the first contact of a counterparty, false afterwards.
        opt_in:
          type: boolean
          description: |
            Your attestation that this person agreed to receive WhatsApp notifications from you.
            Omitting it is a legitimate state: the contact is stored as an address-book entry and
            is never messaged (skip reason `no_optin`).
        opt_in_method: { $ref: "#/components/schemas/OptinMethod" }
        opt_in_attested_by:
          type: string
          description: Who collected the consent. Defaults to the credential that made the call.

    CounterpartyContactUpdateRequest:
      type: object
      properties:
        name: { type: string }
        phone: { type: string }
        counterparty_name: { type: [string, "null"] }
        primary:
          type: boolean
          description: |
            `true` demotes whoever currently holds it, in the same call. Two primaries cannot
            exist even momentarily.
        opt_in:
          type: boolean
          description: "`false` clears the attestation — a consent record that can only ever be added to is worse for the person it exists to protect."
        opt_in_method: { $ref: "#/components/schemas/OptinMethod" }
        opted_out:
          type: boolean
          description: |
            `true` records that the person asked to stop by another channel. `false` is not
            accepted: an opt-out is not reversible through this API.

    ErrorBody:
      type: object
      required: [type, code, message]
      properties:
        type:
          type: string
          enum:
            - invalid_request_error
            - authentication_error
            - permission_error
            - not_found_error
            - conflict_error
            - unprocessable_error
            - rate_limit_error
            - sii_error
            - api_error
        code: { type: string, examples: ["receptor_incomplete"] }
        message:
          type: string
          description: |
            ENGLISH. Machine text, like `code` and `hint` — read `info.description`'s language
            rule. Show it to a person if you must, but branch on `code`: the wording is prose and
            may be reworded, while `code` carries §5.8's twelve-month promise.
        hint:
          type: string
          description: |
            The next action, for the identical code and payload named by `error.doc_url` — the same
            text `GET /error_codes` publishes for this `code`, inlined so a caller does not need a
            second request to learn what to do about it. Absent only for a code minted after your
            client shipped and not yet in your local copy of the dictionary; treat that as "consult
            `error.message` and `error.doc_url`", never as a fault.
        retriable:
          type: boolean
          description: |
            Could the IDENTICAL request succeed later, unchanged? The same field `GET /error_codes`
            publishes for this `code`. Absent under the same rare condition as `hint`.
        retry_after_seconds:
          type: integer
          description: |
            HOW LONG TO WAIT, when `retriable` is `true`. Present on every refusal that carries a
            cadence, and the same value the `Retry-After` and `Facturia-Poll-After` headers carry.

            It is in the BODY as well as in the headers because a consumer that stored the JSON —
            a queue worker replaying a failure, an MCP tool relaying the envelope — no longer has
            the headers. `retriable: true` with no number was half an answer: it said «come back»
            and left «when» to a guess (#511).
        param: { type: string }
        doc_url: { type: string, format: uri }
        request_id: { type: string }
        empresa: { $ref: "#/components/schemas/Rut" }
        existing_id:
          type: string
          description: On conflicts, the id of the object that already exists.
        sii:
          type: object
          description: Present on `sii_error` — the raw upstream estado.
          properties:
            estado: { type: string }
            glosa: { type: string }
            err_code: { type: string }
            track_id: { type: string }
        details:
          description: |
            Machine-readable context **for this `code`**, and the only part of the envelope that
            says "what to do and who does it". It is where the guided path lives: the emission
            refusal carries the `pending_action` a human has to perform, the `402` carries the
            URL where that human pays, and the acuse refusal carries the deadline it was taken on.
            Reading it saves the second request that would otherwise be needed to learn what the
            first response already knew.

            **Its shape is a function of `code`, never of the HTTP status**, so branch on `code`
            first and read `details` second. The variants below are the ones published today;
            §5.8 applies here as everywhere — a code minted after your client shipped may carry a
            shape you do not recognise, and an unrecognised `details` is never a fault. Absent
            entirely for codes that need no context.
          oneOf:
            - $ref: "#/components/schemas/EmissionRefusalDetails"
            - $ref: "#/components/schemas/SubscriptionRequiredDetails"
            - $ref: "#/components/schemas/WindowClosedDetails"
            - $ref: "#/components/schemas/ValidationDetails"
            - $ref: "#/components/schemas/UnknownEndpointDetails"
            - $ref: "#/components/schemas/NoEmpresaReachableDetails"
            - $ref: "#/components/schemas/InsufficientScopeDetails"
            - title: Other
              type: object
              additionalProperties: true
              description: |
                Any other code's context — an artifact refusal's `{status, document_id}`, a
                caracterización's `{refusals}`, a rejected annulment's `{annulled_by}`. Open by
                construction; read what you recognise.
              not:
                anyOf:
                  - required: [onboarding_status]
                    properties: { onboarding_status: {} }
                  - required: [checkout_url]
                    properties: { checkout_url: {} }
                  - required: [reason, sii_code]
                    properties: { reason: {}, sii_code: {} }
                  - required: [problems]
                    properties: { problems: {} }
                  - required: [contract]
                    properties: { contract: {} }
                  - required: [reachable_empresas]
                    properties: { reachable_empresas: {} }
                  - required: [required_scope]
                    properties: { required_scope: {} }

    EmissionRefusalDetails:
      title: EmissionRefusalDetails
      type: object
      description: |
        Carried by `422 empresa_not_emitting` — the refusal that governs the whole guided path
        (`POST /documents` and every other live act on an empresa that has not finished
        onboarding). The handoff travels ON THE REFUSAL, the way an MCP `401` carries
        `WWW-Authenticate: … resource_metadata="…"`: an agent that hits this wall needs more than
        a code, it needs something it can relay to its human.
      required: [onboarding_status, onboarding]
      properties:
        onboarding_status: { $ref: "#/components/schemas/OnboardingStatus" }
        blocked_on:
          type: [string, "null"]
          description: The step key holding everything up, or `null` when nothing is.
          examples: ["mandato"]
        onboarding_url:
          type: string
          deprecated: true
          description: |
            Where to read the whole checklist — `GET /v1/empresas/{empresa_id}/onboarding`. A
            RELATIVE path, not the absolute URL `format: uri` implied: a caller has to prefix the
            host itself. **Deprecated — use `onboarding` instead**, which names the identical
            endpoint as `{method,url}`, absolute (#419). Kept, and still populated, for existing
            callers.

            OUT OF `required` since 2026-09 (#495): a deprecated field cannot be the one a
            consumer is obliged to read.
        onboarding:
          type: object
          description: |
            The same endpoint as `onboarding_url`, structured (#419, contract (d)) — the shape
            `pending_action.link.mint` already ships. Always present.
          required: [method, url]
          properties:
            method: { type: string, const: GET }
            url:
              type: string
              format: uri
              description: Absolute, so this object alone is enough to issue the request.
        pending_action:
          oneOf:
            - $ref: "#/components/schemas/PendingAction"
            - type: "null"
          description: |
            The blocking step's handoff, when the block is a HUMAN's. `null` whenever the blocking
            step is ours or the SII's — which is the honest answer and a different thing from
            "there is nothing to do": it means wait, not act.
        blocked_by:
          type: [string, "null"]
          enum: [status, onboarding, puesta_en_vivo, subscription, null]
          description: |
            Present on the `puesta_en_vivo` variant of this refusal, and declared here because it
            was shipping undeclared (#495). Same vocabulary and same author as
            `Empresa.capabilities.blocked_by` — `emissionEligibility`, the one predicate.
        next:
          type: object
          description: |
            Present alongside `blocked_by`, and the same object `EmissionCapabilities.next`
            carries: who moves, and the `{method, url}` for it. On the `puesta_en_vivo` variant
            `who` is `facturia` — nothing is owed by you or by your user.

    SubscriptionRequiredDetails:
      title: SubscriptionRequiredDetails
      type: object
      description: |
        Carried by `402 subscription_required`. The subscription is per EMPRESA, so the block
        names the empresa it is about and the URL where a human pays for that one; another
        empresa on the same account is unaffected, and the sandbox is never gated.
      required: [empresa, empresa_rut, account_plan, subscription_status, checkout_url]
      properties:
        empresa: { type: string, examples: ["emp_01K2R6ZC4P8QW1VN7T3MHDY9EK"] }
        empresa_rut: { $ref: "#/components/schemas/Rut" }
        empresa_name: { type: [string, "null"] }
        account_plan: { type: string, examples: ["free"] }
        subscription_status: { type: string, examples: ["none", "past_due", "active"] }
        grace_until:
          type: [string, "null"]
          format: date
          description: The last day the lapsed subscription was still honoured, when one was running.
        checkout_url:
          type: string
          format: uri
          description: Where the titular pays, for THIS empresa. Relay it; do not construct it.
        blocked_by:
          type: string
          const: subscription
          description: |
            The same vocabulary `Empresa.capabilities.blocked_by` uses, so an agent that already
            branches on that field needs no second code path (#475). Since eligibility is
            evaluated first, the two can no longer disagree.
        next:
          type: object
          description: |
            WHO MOVES, WHERE, AND HOW YOU LEARN IT IS DONE (#475). This refusal used to carry a
            URL and nothing else, so an agent relaying it had to invent the actor, the role and
            the way to verify — a human step with no handoff, which is the shape ADR-0011 calls
            a P1.
          properties:
            who: { type: string, const: human }
            performed_by:
              type: object
              description: |
                The TITULAR OF THE ACCOUNT, never the representante legal: this is a card, not a
                legal act. The distinction matters — the mandato and the cumplimiento are the
                rep. legal's and cannot be delegated; a payment can.
              properties:
                role: { type: string, const: titular_cuenta }
            method: { type: string, const: GET }
            url:
              type: string
              format: uri
              description: The same page as `checkout_url`, in the `{method, url}` shape #419 fixed everywhere else.
            check:
              type: object
              description: How an agent learns the human is done, without guessing.
              properties:
                method: { type: string, const: GET }
                url: { type: string, format: uri }
                field: { type: string, examples: ["capabilities.blocked_by"] }
                done_when: { type: string, examples: ["it stops reading `subscription`"] }

    UnknownEndpointDetails:
      title: UnknownEndpointDetails
      type: object
      description: |
        Carried by `404 unknown_endpoint` (#458, #535). A guessed URL is the ordinary way an agent
        that has read no documentation starts, so the refusal answers the two questions it has:
        which contract describes the family of paths it aimed at, and — when it missed by a
        character or two — which path we actually serve.
      required: [contract]
      properties:
        contract:
          type: string
          format: uri
          description: |
            The contract covering the path that was asked for. `/account/*` and `/dashboard/*` are
            the BOOTSTRAP document's (`openapi-bootstrap.yaml`) — how you get an account and your
            first key, with no key in hand — and everything else is this one. Naming the right one
            per request is the half a shared `hint` cannot do.
        did_you_mean:
          type: object
          description: |
            The closest declared route, when the requested path is within two edits of one, or
            when the path is exact and only the verb is wrong. Absent when nothing is close: a
            confident wrong pointer is worse than none.
          required: [method, path, url]
          properties:
            method: { type: string, enum: [GET, POST, PATCH, DELETE] }
            path: { type: string, examples: ["/v1/empresas"] }
            url: { type: string, format: uri }

    InsufficientScopeDetails:
      title: InsufficientScopeDetails
      type: object
      description: |
        Carried by `403 insufficient_scope` (#498). The missing scope used to live only in
        `error.message`, against this contract's own rule that a program branches on `code` and
        never on prose — and the hint said «`error.message` names the missing scope», which is
        prose telling a program to parse prose.
      required: [required_scope, mint]
      properties:
        required_scope:
          $ref: "#/components/schemas/Scope"
        mint:
          type: object
          description: |
            The call that produces a key carrying it, for the credential you are holding:
            `POST /v1/api_keys` from a root key, `POST /dashboard/api_keys` from an account
            session. Scopes are fixed at mint time — a key is replaced, never widened.
          required: [method, url]
          properties:
            method: { type: string, const: POST }
            url: { type: string, format: uri }

    NoEmpresaReachableDetails:
      title: NoEmpresaReachableDetails
      type: object
      description: |
        Carried by `400 empresa_required` when the credential reaches NO empresa at all (#457) —
        the first state of every account an agent creates. The header is not the problem there:
        there is nothing to name, so the refusal names the call that creates something to name.
      required: [reachable_empresas, next]
      properties:
        reachable_empresas:
          type: integer
          const: 0
        next:
          type: object
          required: [who, method, url]
          properties:
            who: { type: string, const: agent }
            action: { type: string, const: create_empresa }
            method: { type: string, const: POST }
            url: { type: string, format: uri }

    WindowClosedDetails:
      title: WindowClosedDetails
      type: object
      description: |
        Carried by the three commercial-response refusals of §11.2 —
        `commercial_response_window_closed`, `commercial_response_not_applicable` and
        `409 commercial_response_already_registered`. The engine's own `reason` is finer-grained
        than the codes, and travels here rather than becoming a fourth public code.
      required: [reason, sii_code]
      properties:
        reason:
          type: string
          description: The engine's refusal reason.
          examples: ["window_closed", "prior_reclamo", "doc_type_not_allowed"]
        sii_code:
          type: [integer, "null"]
          description: The SII's own numeric code for this refusal, where it has one.
        deadline_at:
          type: string
          format: date-time
          description: |
            The instant the 8-day window closed (§11.2), rendered in America/Santiago and computed
            on the same civil day the refusal was taken on. Absent only when the document has no
            dated anchor yet.

    ValidationDetails:
      title: ValidationDetails
      type: object
      description: |
        Carried by refusals that check a whole payload and report every fault at once rather than
        one per round trip — `certification_input_invalid` is the one published today. `error.param`
        still names the FIRST offending field, so a client that reads only the contract's own field
        has somewhere to point; the full list is here.
      required: [problems]
      properties:
        problems:
          type: array
          items:
            type: object
            properties:
              code: { type: string, examples: ["invalid_rut"] }
              field:
                type: string
                description: 'Dotted path, as a caller would write it: `proveedores["33:32"].rut`.'
              message: { type: string }

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/ErrorBody" }

    # ---- account

    Account:
      type: object
      properties:
        object: { type: string, const: account }
        id:
          type: [string, "null"]
          description: |
            `acct_…`. NULLABLE, and null for the credentials that predate the account system
            (migration 001's internal clients) — a key with no account resolves to no id here.
            Every credential a self-serve caller can obtain carries one.
        name:
          type: [string, "null"]
          description: |
            The account's `company_name`, falling back to `name`. NULLABLE, and **null is the
            ordinary case for an agent's cold start** (#473): `POST /account/register` takes
            neither field, so an account created by an agent has no name until a human sets one
            in the panel. It was declared `type: string` while the wire sent `null`, which a
            strict generated client rejects on the very first call of the guided path.
        key: { $ref: "#/components/schemas/ApiKey" }
        empresas_ready:
          type: [integer, "null"]
          description: |
            Of the empresas `key.empresas` reaches, how many `emissionEligibility` says CAN
            EMIT right now — the same predicate `POST /v1/documents` refuses with and
            `Empresa.capabilities` reports per empresa (#416). An agent holding a `["*"]` key
            over forty empresas answers "how many can I invoice with today" in one call instead
            of forty.

            `null` means the summary could not be computed this time (never a `0` somebody
            would act on) — `empresas_total` still reports the credential's reach.
        empresas_total:
          type: integer
          description: |
            How many empresas this credential can REACH — its own allowlist resolved to real
            rows, not the account's total headcount. An allowlisted key is answered about its
            own reach.
        limits:
          type: object
          properties:
            requests_per_minute: { type: integer }
            emissions_per_second: { type: integer }

    Scope:
      type: string
      enum:
        - documents:read
        - documents:write
        - received:read
        - received:write
        - empresas:read
        - empresas:write
        - folios:write
        - tax:read
        - tax:write
        - webhooks:read
        - webhooks:write
        - usage:read
        - keys:write
        - mandato:write
        - mandato:attest
        - onboarding:write
        - onboarding:attest

    ApiKey:
      type: object
      properties:
        object: { type: string, const: api_key }
        id: { type: string }
        name: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        root:
          type: boolean
          description: |
            Root keys carry only `keys:write`: they mint, roll and revoke other keys and can do
            nothing else — no emission, no document reads, no tax data.
        last4: { type: string }
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        empresas:
          type: array
          description: Allowlist. `["*"]` means every empresa on the account, present and future.
          items: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }

    ApiKeyWithSecret:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          properties:
            secret:
              type: string
              description: Returned once, on creation or roll. Never readable again, not even by a root key.
              examples: ["sk_live_qT8vN2wLmR5xKpJ7hYdCbA3s"]

    # ---- empresa

    EmpresaStatus:
      type: string
      enum: [onboarding, active, suspended, archived]

    Origen:
      type: [string, "null"]
      enum: [gratuito, mercado, ninguno, null]
      description: |
        How the empresa emits today, as you declare it. `gratuito` = it uses the SII's own
        Sistema de Facturación Gratuita; `mercado` = it already uses a market provider;
        `ninguno` = it does not emit electronically yet. `null` only on empresas created
        before the field existed — never declared, and deliberately not backfilled.

        It routes the onboarding copy we show and nothing else. **Whether an empresa may skip
        certification is decided 100% empirically — a claimed resolución only earns the skip
        once matched against a real operation actually performed in that capacity, never a
        portal reading — and the value you declare here is never an input to that decision.**
        Enrolment in the free system makes a RUT an emisor of real DTEs without a resolución
        de mercado, so a declared `mercado` we trusted would skip certification for exactly
        the clients who still need it.

    TaxpayerIvaShape:
      type: [object, "null"]
      description: |
        Which certification shape the SII put this taxpayer in, and how we know. **Read-only** —
        it is the SII's answer about your empresa, not a setting: no request body can write it.

        The SII decides, by itself, whether a contribuyente has activities afectas a IVA, and
        that decision issues a DIFFERENT set de pruebas. An `exenta` taxpayer is issued guías de
        despacho, facturas exentas, a libro de guías and a libro de compras **para exentos** —
        and **no set básico at all** — with boletas tipo 41. An `afecta` one is issued the set
        básico and the two libros, with boletas tipo 39.

        `null` means we have not established it yet. It is never a default: assuming `afecta`
        for an exento contribuyente would build eleven documents the SII will never review.
      properties:
        object: { type: string, const: taxpayer_iva_shape }
        shape:
          type: string
          enum: [afecta, exenta]
        coniva:
          type: [string, "null"]
          enum: [S, N, null]
          description: |
            The SII's own answer, verbatim — the hidden `CONIVA` field of the postulación form.
            `null` when we derived the shape from the set de pruebas instead of reading the
            field, which is a weaker claim and is reported as such rather than smoothed over.
        source:
          type: string
          enum: [pe_datos_empresa, set_file]
          description: |
            `pe_datos_empresa` = a capture of the SII's own answer. `set_file` = a
            classification of the set de pruebas the SII issued for this empresa: strong
            evidence about the shape, but not a reading of `CONIVA`.
        observed:
          type: boolean
          description: |
            Whether the `CONIVA` → set-list mapping being applied was captured or is predicted.
            The exenta mapping was observed in full on 2026-08-21; the afecta one is inferred
            and is reported as such until a `CONIVA=S` page is captured.
        observed_at: { type: [string, "null"], format: date-time }
        expected_sets:
          type: array
          description: The `set_kind` values this taxpayer's set de pruebas round should contain.
          items:
            type: string
            enum: [basico, exenta, guia, libro_guias, libro_ventas, libro_compras, boleta]
        expected_set_titles:
          type: array
          items: { type: string }
        expected_total_documents:
          type: [integer, "null"]
          description: The `TOTAL` the SII prints for this shape, when it is known.
        boleta_tipo:
          type: [integer, "null"]
          enum: [39, 41, null]
        help: { type: string, description: "Spanish, customer-facing." }
        detail: { type: string, description: Spanish. Where the answer came from. }

    Empresa:
      type: object
      properties:
        object: { type: string, const: empresa }
        id: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        rut: { $ref: "#/components/schemas/Rut" }
        legal_name: { type: string }
        giro:
          type: [string, "null"]
          description: |
            NULLABLE (#496). `POST /v1/empresas` does not require it and stores `null` when it is
            omitted, which is the ordinary create — the same class of lie #473 found on
            `contact_email`, caught by validating a real body against this schema instead of
            comparing the keys of a hand-written example.
        acteco:
          type: array
          items: { type: integer }
        address: { $ref: "#/components/schemas/EmpresaAddress" }
        codigo_sucursal:
          type: [integer, "null"]
          minimum: 0
          description: |
            The SII branch code (`CdgSIISucur`) this empresa emits from. `null` is casa matriz,
            which is most empresas — and it is REPORTED as null rather than omitted, because
            "we hold no sucursal for you" is a fact an SFG wizard has to be able to see: the
            Portal MIPYME's own preview refuses a document whose emisor origin is incomplete.
        contact_email:
          type: [string, "null"]
          format: email
          description: |
            NULLABLE, and null is what an empresa created without one reports (#473). It was
            declared `type: string` while the wire sent `null` for every empresa that never
            supplied it — the ordinary case — so a strict generated client (pydantic, quicktype)
            rejected the response of `GET /v1/empresas/{id}`, the read that carries
            `capabilities` and the onboarding handoff.
        ambiente:
          type: string
          enum: [certificacion, produccion]
          description: |
            Which SII environment live emissions target. **READ-ONLY AND DERIVED** — it is not
            accepted on create or update, and sending it is refused with `400 invalid_field`.

            The rule: `emission_channel: sfg` is always `produccion`, because the SII
            habilitated the empresa when it enrolled in the free system. `emission_channel:
            mercado` is `certificacion` until the SII issues its resolución, which is the only
            thing that moves it — or from the start when a stated `origen: mercado` resolución
            is verified by our own probe.

            It was a request field with `default: produccion` until 2026-09, and the applied
            default was the opposite, so a generated client that filled schema defaults created
            uncertified empresas in production. State `origen` and `emission_channel`; read this
            back.
        emission_channel:
          type: string
          enum: [mercado, sfg]
          default: mercado
          description: |
            HOW THIS EMPRESA EMITS TODAY. `mercado` is Facturia's own certified engine; `sfg`
            drives the SII's free Sistema de Facturación Gratuito on the empresa's behalf, with
            folios the portal manages.

            The titular states it — it is a preference, not something we can read off the SII —
            and it decides which checklist, which tipos (`sfg` has no boleta form and no tipo
            46) and which `ambiente`. A stored NULL reads as `mercado`, so this is never null.
        certification_track:
          type: [string, "null"]
          enum: [mercado, sfg, null]
          description: |
            WHICH CERTIFICATION CHECKLIST GOVERNS IT — a different question from
            `emission_channel`, and the two can legitimately disagree: an empresa can emit
            through the free portal while certifying for mercado, which is exactly what the
            pilot does.

            `null` is a real answer and the honest permanent state of a customer content on the
            free portal for ever: not pursuing certification. It folds the short SFG checklist.
        doc_types:
          type: array
          items: { $ref: "#/components/schemas/TipoDte" }
        origen: { $ref: "#/components/schemas/Origen" }
        resolucion:
          $ref: "#/components/schemas/ResolucionState"
        resolucion_boletas:
          $ref: "#/components/schemas/ResolucionBoletasState"
        reception_email:
          type: [string, "null"]
          format: email
          description: |
            Per-empresa reception address, `<rut>@dte.facturia.cl` — the address the empresa
            registers with the SII as Mail Contacto Empresas.

            `null` FOR A SANDBOX EMPRESA (#450). A sandbox empresa reserves nothing: not the RUT
            (uniqueness is per mode) and not this alias. Publishing one for a scratch empresa
            would publish an address that routes a real company's supplier documents. Inbound
            reception is a live-mode fact.
        status: { $ref: "#/components/schemas/EmpresaStatus" }
        iva_shape: { $ref: "#/components/schemas/TaxpayerIvaShape" }
        onboarding:
          type: object
          description: |
            The same derivation `GET .../onboarding` uses (#414), so the two cannot disagree —
            they used to: `status` was the raw spine column and `next_action_url` filled in only
            when it read the literal string `action_required`, which the machine never stores.
          properties:
            status: { $ref: "#/components/schemas/OnboardingStatus" }
            progress: { type: number, minimum: 0, maximum: 1 }
            blocked_on:
              type: [string, "null"]
              description: The step key holding this empresa up, or null. Same field as `GET .../onboarding`'s `blocked_on`.
            next_action_url:
              type: [string, "null"]
              format: uri
              description: |
                ALWAYS `null` here (contract (d) — never a URL that opens «Falta el enlace»). A
                capability-token onboarding page is a link scoped to ONE empresa and this object
                does not hold one; `url` below is where to get one.
            url:
              type: string
              format: uri
              description: |
                `GET .../onboarding` for this empresa — the full handoff (`pending_action`,
                including `pending_action.link.mint` to acuñar a live action URL) that this
                summary object does not carry.

                ABSOLUTE (#473, #419): `https://api.facturia.cl/v1/empresas/{id}/onboarding`, the
                same origin `pending_action.link.mint.url` and `verification.recheck.url` carry.
                It was a relative path under a `format: uri` declaration, which is a lie a strict
                client rejects and a URL an agent cannot dereference.
        capabilities: { $ref: "#/components/schemas/EmissionCapabilities" }
        created_at: { type: string, format: date-time }

    ResolucionState:
      type: [object, "null"]
      description: |
        **«¿YA EMITES CON OTRO PROVEEDOR?»** — the resolución this empresa holds, and whether we
        have PROVED it (ruling 2026-09-07).

        The SII certifies the *contribuyente*, not the software. An empresa that already invoices
        through another provider holds a resolución the SII issued to its RUT, and that resolución
        does not stop being valid because it changed vendors — so it can state it on
        `POST /v1/empresas` or `PATCH /v1/empresas/{empresa_id}`, and a verified one skips the whole
        certification leg.

        **`verified` NEVER COMES FROM THE CLAIM.** Stating a resolución changes nothing on its own.
        The claim waits as `verification_status: pending_verification` until a Facturia operator
        confirms, for this empresa, the ONE production timbraje that verifies it
        (`POST /v1/empresas/{empresa_id}/resolucion/verify`; `verification_request` is that exact
        request). Palena refuses a RUT it has not authorized, so a granted range is the proof
        (`verified`) and a refusal is the answer too (`refused`, with the SII's words in
        `refusal`). Only that act — or the SII issuing the resolución to us at the end of a
        certification we ran — sets `verified: true`. Nothing is asked of the SII implicitly
        (sii-api#581 D1, 2026-09-23).

        Branch on `verified`, never on the presence of `numero`. While it is `false` the empresa is
        walking the ordinary checklist and `onboarding.blocked_on` says where it is; when the probe
        was refused, `GET .../onboarding`'s `blocked_reason.resolucion_claim` carries the SII's own
        sentence.

        `null` for an empresa that never claimed one and has not been issued one.
      properties:
        numero:
          type: [integer, "null"]
          minimum: 1
          description: |
            The número de resolución exenta, as stated or as the SII issued it. `null` for an
            empresa whose resolución we hold only as a date.

            **Where to find it:** on your own invoices. Every DTE carátula carries `NroResol` and
            `FchResol`, so the pair is printed on every document this empresa has already emitted
            with its current provider.
        fecha:
          type: [string, "null"]
          format: date
          description: The fecha de resolución, `YYYY-MM-DD` — the `FchResol` beside `NroResol` on the same carátula.
        verified:
          type: boolean
          description: |
            Whether an ACT established this resolución is real — our own production timbraje
            succeeded for a declared factura tipo, or the SII issued it to us directly. Never
            derived from `numero` being present.
        verified_at:
          type: [string, "null"]
          format: date-time
          description: When that act happened. `null` exactly when `verified` is `false`.
        verification_status:
          type: [string, "null"]
          enum: [pending_verification, verified, refused, null]
          description: |
            Where the claim's verification stands (sii-api#581 D1).

            - `pending_verification` — claimed, not yet answered by the SII: waiting for a
              Facturia operator to confirm the production timbraje, in flight, or an attempt the
              SII did not answer about the RUT.
            - `verified` — the SII granted the range (or issued the resolución to us).
            - `refused` — the SII answered the confirmed request without a range. Re-stating the
              resolución with `PATCH` resets it to `pending_verification`.

            `null` when nothing was claimed (for example `numero` is `null`) and nothing verified.
        verification_request:
          type: [object, "null"]
          description: |
            The exact request that verifies (or verified) the claim: ONE production timbraje of
            `quantity` folios of `tipo_dte`. `null` when there is no claim, or the empresa declared
            no factura-family tipo to verify it with.
          properties:
            tipo_dte: { $ref: "#/components/schemas/TipoDte" }
            quantity: { type: integer, minimum: 1 }
            ambiente: { type: string, enum: [produccion] }
        refusal:
          type: [object, "null"]
          description: The SII's answer when `verification_status` is `refused`, verbatim. `null` otherwise.
          properties:
            code: { type: string }
            message: { type: string }
            at: { type: [string, "null"], format: date-time }

    ResolucionVerifyRequest:
      type: object
      required: [confirm, authorized_by]
      description: The operator's explicit confirmation of the one production timbraje.
      properties:
        confirm:
          type: boolean
          description: Must be `true`. Anything else is `400 confirmation_required` and nothing is sent.
        authorized_by:
          type: string
          minLength: 1
          description: The person confirming. Recorded beside the SII's answer.
        reason:
          type: [string, "null"]
          description: Why, in a sentence (the ticket, the customer). Recorded.

    ResolucionVerification:
      type: object
      description: The recorded confirmation and the one job it queued.
      properties:
        object: { type: string, enum: [resolucion_verification] }
        empresa: { type: string }
        verification_status: { type: string, enum: [pending_verification] }
        state: { type: string, enum: [requested] }
        request:
          type: object
          properties:
            tipo_dte: { $ref: "#/components/schemas/TipoDte" }
            quantity: { type: integer, minimum: 1 }
            ambiente: { type: string, enum: [produccion] }
        confirmed_at: { type: string, format: date-time }
        confirmed_by: { type: string }
        reason: { type: [string, "null"] }
        job:
          type: object
          properties:
            id: { type: string }
            object: { type: string, enum: [job] }
            type: { type: string, enum: [folio_request] }
            status: { type: string, enum: [queued] }

    ResolucionBoletasState:
      type: [object, "null"]
      description: |
        The boleta half of the same question (ruling 2026-09-07). The SII habilitates boleta
        electrónica separately from the resolución, and its answer is a DATE with no número:
        «habilitados los documentos de boleta electrónica a contar del …».

        `verified` is the same gate `POST /v1/documents` reads before building a 39 or 41, so it is
        `true` both for an empresa that arrived habilitated (its claim proved by a tipo-39/41
        timbraje) and for one whose habilitación we ran. `fecha: null` with `verified: true` is the
        ordinary state of the second kind.

        `null` for an empresa that neither claimed a habilitación nor has one.
      properties:
        fecha:
          type: [string, "null"]
          format: date
          description: The «a contar del» date, as the titular stated it. `null` when it was never claimed.
        verified:
          type: boolean
          description: Whether the SII has habilitated this empresa for boleta electrónica — the gate 39/41 emission reads.
        verified_at:
          type: [string, "null"]
          format: date-time

    ResolucionClaimRequest:
      type: [object, "null"]
      required: [numero, fecha]
      description: |
        **THE RESOLUCIÓN THIS EMPRESA ALREADY HOLDS** (ruling 2026-09-07). Send it when the empresa
        already invoices electronically through another provider; send `null` to clear it.

        Both halves are printed on the carátula of every documento tributario electrónico the
        empresa emits today (`NroResol`, `FchResol`), so an agent can read them off a recent
        invoice rather than sending anybody to Mi SII.

        **IT IS A CLAIM, NOT A SKIP.** It writes two columns and moves nothing else: the empresa is
        still created at `mandato_pending`, still needs the certificate and the mandato, and still
        certifies unless our own production timbraje proves the resolución. That timbraje is made
        only when a Facturia operator confirms it for this empresa; until then the claim reads
        `resolucion.verification_status: pending_verification`. Read `resolucion.verified` on the
        empresa object for the outcome.
      properties:
        numero:
          type: integer
          minimum: 1
          description: |
            The número de resolución exenta (`NroResol`). `400 invalid_field` below 1 — `0` is the
            placeholder we write ourselves while an empresa is still certifying, so it is never a
            valid claim.
        fecha:
          type: string
          format: date
          description: |
            The fecha de resolución (`FchResol`), `YYYY-MM-DD`. `400 invalid_field` if it is in the
            future: a resolución the SII has not issued is not one this empresa emits under today.

    ResolucionBoletasClaimRequest:
      type: [object, "null"]
      required: [fecha]
      description: |
        **THE BOLETA HABILITACIÓN THIS EMPRESA ALREADY HOLDS** (ruling 2026-09-07). Send it
        alongside `doc_types` containing 39 or 41; send `null` to clear it.

        It carries no `numero` because the SII issues none for boletas: the habilitación arrives as
        «habilitados los documentos de boleta electrónica a contar del …», and that date is the
        whole of it. Verified by a tipo-39/41 production timbraje, exactly as the resolución claim
        is; refused, the ordinary habilitación steps appear on the checklist instead.
      properties:
        fecha:
          type: string
          format: date
          description: |
            The «a contar del» date, `YYYY-MM-DD`. `400 invalid_field` if it is in the future: a
            habilitación that has not started is not one this empresa issues boletas under today.

    EmissionCapabilities:
      type: object
      description: |
        «May this empresa emit, right now, and if not, who has to do what?» (#416, ADR-0011's
        2026-09-02 addendum §2) — computed by the SAME function `POST /v1/documents` refuses
        emission with, so this object and a `422` can never disagree.

        `blocked_by` REPORTS `subscription` ON `GET /v1/empresas/{empresa_id}` and not on the
        LIST (#475, ruling 3): the single-object read asks the billing gate (one indexed read),
        the list does not, because that would be one billing lookup per row of every page.
        Absent means «not asked», never «fine». When it does report `subscription`, `next.url`
        is the checkout page for THAT empresa — the same URL the `402` publishes as
        `details.checkout_url`, so the object and the refusal name one page, not two.

        **`capabilities.tipos` IS THE EMISSION TRUTH; `doc_types` IS THE DECLARATION** (#493).
        They answer different questions and used to be published side by side with nothing
        saying which governs: `doc_types` is what the titular declared they intend to emit — it
        drives the CERTIFICATION checklist and which folios are requested — and `tipos` is what
        this empresa can emit right now, which is `doc_types` narrowed to what the current
        `emission_channel` supports (`sfg` has no boleta form and no tipo 46). Branch on
        `tipos`. To emit a tipo that is not in it, add it to `doc_types` with
        `PATCH /v1/empresas/{id}` (and, for 39/41, be on the `mercado` channel).
      properties:
        can_emit: { type: boolean }
        tipos:
          type: array
          items: { $ref: "#/components/schemas/TipoDte" }
          description: |
            What this empresa can emit NOW: the declared `doc_types` narrowed to what the current
            `emission_channel` supports. This is the list to branch on before building a
            document. A sandbox empresa reports the whole channel list — the sandbox emits every
            tipo without declaring anything.
        blocked_by:
          type: [string, "null"]
          enum: [status, onboarding, puesta_en_vivo, subscription, null]
          description: |
            `status` — the empresa itself is suspended or archived.
            `onboarding` — the checklist has not reached `emitting`; see `onboarding.blocked_on`.
            `puesta_en_vivo` — everything the customer owes is done and OURS is not yet: a
            waiting state, never an errand.
            `subscription` — live emission needs a live subscription for this empresa.
        next:
          type: object
          properties:
            who:
              type: string
              enum: [agent, human, facturia]
              description: Never `customer` — an agent relays to a human, it does not address one directly.
            api:
              type: [string, "null"]
              description: |
                DEPRECATED, kept in parallel (#419, #493). The endpoint that answers or fixes it,
                as one `"METHOD /path"` string. Branch on `method` + `url` below; this field
                stays for the callers that already read it and is never removed inside the
                12-month window §5.8 promises.
            method:
              type: [string, "null"]
              enum: [GET, POST, PATCH, DELETE, null]
              description: |
                The verb of `url` — the structured half of `api`, in the `{method, url}` shape
                every other handoff in this contract uses.
            url:
              type: [string, "null"]
              format: uri
              description: |
                ABSOLUTE URL to perform or read the next action — the endpoint named by `api`,
                dereferenceable as it stands (`https://api.facturia.cl/v1/…`). It was declared as
                "a page a person can be sent to" and was `null` in every branch, so nothing that
                ever shipped read a non-null value: this is the field taking on the job it was
                always supposed to do. A page for a person is `GET .../onboarding`'s
                `pending_action.action_url`, which is minted per act and single-use.

    EmpresaCreateRequest:
      type: object
      required: [rut, legal_name]
      properties:
        rut: { $ref: "#/components/schemas/Rut" }
        legal_name: { type: string }
        giro: { type: string }
        acteco:
          type: array
          items: { type: integer }
        address: { $ref: "#/components/schemas/Address" }
        codigo_sucursal:
          type: [integer, "null"]
          minimum: 0
          description: SII branch code (`CdgSIISucur`). Omit, or send null, for casa matriz.
        contact_email: { type: string, format: email }
        emission_channel:
          type: string
          enum: [mercado, sfg]
          default: mercado
          description: |
            How this empresa will emit. `mercado` runs Facturia's certified engine and the
            eleven-step certification; `sfg` drives the SII's free Sistema de Facturación
            Gratuito for an empresa that is already enrolled there, and runs a short checklist
            with no certification in it.

            Choosing `sfg` for an empresa that is NOT enrolled does not enrol it: the
            `emisor_verificado` step will say so. Choosing `mercado` for one that is happy on
            the free portal starts a certification it does not need.

            `ambiente` is DERIVED from this and is not accepted here — see `Empresa.ambiente`.
        doc_types:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/TipoDte" }
          default: [33, 34, 61]
          description: |
            WHAT THIS EMPRESA INTENDS TO EMIT — the declaration that drives certification and
            which folios are requested; `capabilities.tipos` is what it can emit NOW. Defaults
            to `[33, 34, 61]`; an explicit `[]` is `400 invalid_field`, because an empresa with
            zero tipos can never finish certification.

            Declaring 39 or 41 (boletas) on `emission_channel: sfg` is
            `422 doc_type_not_supported_on_channel`: the SII's free Portal MIPYME exposes no
            boleta form. Boletas need the `mercado` channel, where they also add the
            `habilitacion` step to the checklist.
        origen:
          type: string
          enum: [gratuito, mercado, ninguno]
          description: |
            **REQUIRED IN LIVE MODE** — an `sk_live_` key that omits it gets
            `400 invalid_field` with `param: "origen"`, which is the first empresa of every real
            customer. It is not in this schema's `required` list because the mode is a property of
            the credential and not of the body; the operation's `400` declares the refusal (#495).

            Optional in test mode, where it defaults to `ninguno` because a sandbox empresa is
            born emitting and never meets the wizard this field routes. See the `Origen` schema
            for what it does and does not decide.
        resolucion:
          $ref: "#/components/schemas/ResolucionClaimRequest"
        resolucion_boletas:
          $ref: "#/components/schemas/ResolucionBoletasClaimRequest"

    EmpresaUpdateRequest:
      type: object
      properties:
        legal_name: { type: string }
        giro: { type: string }
        acteco:
          type: [array, "null"]
          items: { type: integer }
          description: |
            SII activity codes, most specific first; the first one becomes `<Acteco>` on the
            emisor block of every document. An empty array — or null — clears it. Omitting the
            field leaves the stored value alone.
        address: { $ref: "#/components/schemas/Address" }
        codigo_sucursal:
          type: [integer, "null"]
          minimum: 0
          description: |
            SII branch code (`CdgSIISucur`); null clears it back to casa matriz. Omitting the
            field leaves the stored value alone — a distinction that matters here, because an
            SFG empresa's Portal MIPYME preview refuses a document whose emisor origin is
            incomplete.
        contact_email: { type: string, format: email }
        doc_types:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/TipoDte" }
          description: |
            WHAT THIS EMPRESA INTENDS TO EMIT — the declaration that drives certification and
            which folios are requested. Read `capabilities.tipos` for what it can emit NOW.

            An empty array is `400 invalid_field`: an empresa with zero tipos can never finish
            certification. Omitting the field leaves the stored value alone.

            ADDING a tipo to an empresa already `emitting` re-opens its checklist at
            `certification_in_progress` — certification and CAF are per tipo, and the SII
            authorizes only the types you certified. Adding 39 or 41 is what makes the
            `habilitacion` step appear, ON EITHER CHANNEL.

            BOLETAS ARE ALLOWED ON THE `sfg` CHANNEL, and declaring one is how you ask for the
            habilitación. The SII's free Portal MIPYME exposes no boleta form to anybody, so a
            39/41 always leaves through our own engine whatever the empresa emits through —
            which means an empresa can keep issuing its facturas on the free portal and have us
            run the boleta certification for it in the background. The `habilitacion` and
            `folios` steps appear on the free lane's checklist the moment you declare a boleta,
            and `capabilities.tipos` gains 39/41 once the SII habilitates them.

            TIPO 46 ON THE `sfg` CHANNEL IS `422 doc_type_not_supported_on_channel`: unlike a
            boleta, it is not a document our engine builds on that lane, so it needs
            `emission_channel: "mercado"`. The same refusal fires when `emission_channel` is
            moved to `sfg` while a 46 is already declared.
        certification_track:
          type: [string, "null"]
          enum: [mercado, sfg, null]
          description: |
            WHICH CHECKLIST GOVERNS THIS EMPRESA — settable since #474, because the code has
            always read it and no surface could state it: `PATCH {emission_channel: "sfg"}`
            moved the tipos, the ambiente and the driver and left the empresa on the ten-step
            mercado checklist with `blocked_on: mandato`, a list an SFG emisor never runs.

            `null` means «not pursuing certification» and is a real, permanent answer. Setting
            it to `null` or to `sfg` HIDES the mercado checklist WITHOUT deleting anything: the
            step records stay exactly as they are and setting the track back to `mercado` shows
            them again, unchanged. It is a projection, not a destructive write.
        origen:
          type: string
          enum: [gratuito, mercado, ninguno]
          description: Correctable, and the only way an empresa created before the field existed can acquire one.
        emission_channel:
          type: string
          enum: [mercado, sfg]
          description: |
            Correctable, like `origen`: an empresa can move onto the SFG lane or off it.
            Moving to `sfg` sets `ambiente` to `produccion` in the same write, because an
            empresa in the free system is a production emisor. Moving to `mercado` leaves
            `ambiente` alone — the resolución is the only thing that moves it the other way.

            It does NOT change `certification_track` on its own — that is a separate field on
            this same request body (#474). Those are two facts and each is stated where it
            belongs.
        resolucion:
          $ref: "#/components/schemas/ResolucionClaimRequest"
        resolucion_boletas:
          $ref: "#/components/schemas/ResolucionBoletasClaimRequest"
        status:
          type: string
          enum: [active, archived]

    Certificate:
      type: object
      properties:
        object: { type: string, const: certificate }
        empresa_id: { type: string }
        subject_rut: { $ref: "#/components/schemas/Rut" }
        subject_name: { type: string }
        issuer: { type: string }
        serial: { type: string }
        valid_from: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        status: { type: string, enum: [active, expiring, expired, revoked] }
        authorized_empresas:
          type: array
          description: |
            RUTs this certificate is a SII-authorized user of, read from the SII's own
            «Mantención de usuarios autorizados» once the verification has run. Before it has,
            this falls back to the empresas of your account where the same certificate is
            installed — which is a floor, not the SII's answer.
          items: { $ref: "#/components/schemas/Rut" }
        uploaded_at: { type: string, format: date-time }

    SfgCredential:
      type: object
      description: |
        Custody metadata for the clave of the «certificado CENTRALIZADO» the SII already holds
        for an empresa in the Sistema de Facturación Gratuito. It is what authorizes the SII to
        sign a DTE server-side, and it is the ONLY secret this endpoint knows about.

        IT IS NOT THE CLAVE TRIBUTARIA. Facturia custodies none, ever.
      properties:
        object: { type: string, const: sfg_credential }
        empresa_id: { type: string }
        clave_centralizado:
          type: object
          description: |
            Whether we hold one, and since when. That is everything this API will ever say about
            the value: no length, no hint, no masked form, no verification echo — each of those
            is an oracle, and an oracle over a short password is a way to guess it.
          properties:
            stored: { type: boolean }
            stored_at:
              type: [string, "null"]
              format: date-time
              description: Null when nothing is stored, and on rows that predate the column.
        purpose: { type: string, const: sfg_emission_centralizado_signing }
        queued:
          type: boolean
          description: |
            Whether the onboarding tick was enqueued (2026-09, #529). Storing the clave used to
            move nothing, so the panel said "la tenemos guardada" while the checklist on the same
            screen still said `clave_centralizado: pending` - two answers to one question.
            `false` only on an insert failure; the hourly sweep still catches it.
        next:
          allOf:
            - $ref: "#/components/schemas/ApiCall"
            - description: |
                Where to read the result (2026-09, #536). Every other mutation in this flow hands
                back a `next`; this one did not, so an integration had to know from outside the
                response that the clave is followed by the delegation check and the puesta en vivo.
        pending_action:
          oneOf:
            - $ref: "#/components/schemas/PendingAction"
            - type: "null"
          description: |
            The handoff of whatever step comes next, or `null` when the next step is ours. The
            same object `GET .../onboarding` publishes, from the same builder.

    SfgCredentialCreateRequest:
      type: object
      required: [clave_centralizado]
      properties:
        clave_centralizado:
          type: string
          maxLength: 256
          description: |
            The clave of the empresa's certificado CENTRALIZADO at the SII.

            ONE FIELD NAME, NO SYNONYMS: `clave`, `password` and `clave_tributaria` are refused
            with `400 invalid_field` rather than coerced. The one mistake this endpoint exists to
            make impossible is a customer handing us their SII portal password under a field name
            we were relaxed about.

    MandatoCreateRequest:
      type: object
      description: |
        Omit the whole body to have Facturia collect the signature instead; the response then
        carries a single-use `signing_url`. Send `representante_legal` to record an acceptance
        you collected yourself.

        **Both shapes are closed (ticket #489).** Which act this call performs is decided by the
        body, so an unrecognised field is refused with `400 invalid_field` naming it, rather
        than read as "the empty body" — which used to mint a fresh `signing_url` and silently
        kill the link the representante legal was already holding. The mint form accepts
        `requested_by` and nothing else; the acceptance form accepts exactly the fields below.
      properties:
        requested_by:
          allOf:
            - $ref: "#/components/schemas/RequestedBy"
            - description: |
                Who asked for the link, relayed to the hosted page so the person reading it is
                told «Este enlace lo pidió <name>» instead of being left to guess. Only valid on
                the mint form (no `representante_legal`). Defaults to the credential: a session
                is `panel`, an API key is `api` under the key's own name.
        representante_legal:
          type: object
          required: [rut, name]
          properties:
            rut: { $ref: "#/components/schemas/Rut" }
            name: { type: string }
            email: { type: string, format: email }
        accepted_at:
          type: string
          format: date-time
          description: When the representante legal accepted. Clamped to now if it is in the future.
        accepted_ip: { type: string }
        accepted_user_agent: { type: string }
        terms_version: { type: string }
        evidence:
          type: object
          description: |
            The same facts in the shape a signing page naturally produces. Accepted as an
            alternative to the flat fields above, and kept verbatim in the rendición de
            cuentas alongside whatever we recorded ourselves.
          properties:
            method: { type: string }
            ip: { type: string }
            user_agent: { type: string }
            timestamp: { type: string, format: date-time }

    Mandato:
      type: object
      properties:
        object: { type: string, const: mandato }
        empresa_id: { type: string }
        status:
          type: string
          enum: [pending, signed, revoked, none]
          description: |
            `pending` - a signing link is outstanding. `signed` - accepted (check `terms_current`
            before trusting it). `revoked` - clausula 7.1 was exercised; the empresa is
            suspended. `none` - nobody has been asked yet. `none` is only ever returned by
            `GET`, and it is a state rather than a `404`: the empresa exists.
        terms_version: { type: [string, "null"] }
        terms_hash:
          type: [string, "null"]
          description: |
            `sha256:…` over the exact bytes accepted, snapshotted from `GET /v1/mandato_terms`
            at the moment of acceptance. It does not follow later amendments of the document.
            `null` when `status` is `none`.
        terms_current:
          type: boolean
          description: |
            `false` when `terms_version`/`terms_hash` no longer match the CURRENT
            `GET /v1/mandato_terms`. On a `signed` mandato this means the onboarding
            pipeline has already stopped trusting the acceptance and is asking for a
            fresh one — POST again to clear it.
        representante_legal:
          type: [object, "null"]
          properties:
            rut: { $ref: "#/components/schemas/Rut" }
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
        signing_url:
          type: [string, "null"]
          format: uri
          description: |
            Present when Facturia is collecting the signature on your behalf. Returned EXACTLY
            ONCE: only a hash of its token is stored, so no later read can reproduce it.
        signing_expires_at: { type: [string, "null"], format: date-time }
        signed_at: { type: [string, "null"], format: date-time }
        acceptance_method:
          type: [string, "null"]
          description: |
            How the acceptance reached us. `hosted` is an *aprobacion*: a human of the required
            role completed a page that showed them exactly what they were signing, which is the
            only form of explicit approval Facturia records. `api` is an *atestacion*: an
            integrator collected the signature in its own flow and asserted it over the API,
            behind the dedicated `mandato:attest` scope. `dashboard` is the titular signing in
            session on the same page component.

            Published so an auditor can tell the two apart without opening the database. Free
            text, because a later channel should not need a schema change to be recordable.
        revoked_at: { type: [string, "null"], format: date-time }
        superseded_at:
          type: [string, "null"]
          format: date-time
          description: >-
            When a newer version of the terms made this acceptance history (ticket #399). A superseded
            acceptance is kept as a record and never counts as current; `terms_current` is the field
            that says whether the signature on file still binds. `null` while the acceptance is current.
        pending_action:
          oneOf:
            - $ref: "#/components/schemas/PendingAction"
            - type: "null"
          description: |
            Present only on the `POST` that mints a `signing_url`, with `action_url` set to that
            same live URL: the mint is the one moment an address that works and the instructions
            to relay with it exist together. The same object `GET .../onboarding` publishes for
            the `mandato` step.
        revocation_url:
          type: [string, "null"]
          format: uri
          description: |
            Present only on the `POST` that RECORDS an acceptance. Cláusula 7.1's own address,
            minted in the same act and returned once: the person who just signed leaves holding
            the thing the contract promises them. `null` when it could not be minted — the
            signature stands either way, and `POST .../mandato/revocation_link` re-mints it.
        receipt:
          type: object
          description: |
            Present only on the `POST` that RECORDS an acceptance (ticket #527). What the page is
            allowed to SAY about the copy — it used to promise «te mandamos una copia a …» from
            the mere presence of an address, while the log at that second said no mail provider
            was configured.
          properties:
            email: { type: [string, "null"] }
            email_delivered:
              type: boolean
              description: >-
                What the transport reported. `false` means say so and offer the download —
                never «it is on its way».
            error: { type: [string, "null"], description: "English, for logs. Null when delivered." }
            terms_version: { type: string }
            terms_hash: { type: string }
            terms_url:
              type: string
              format: uri
              description: >-
                `GET /v1/mandato_terms?version=<the version accepted>` — the exact bytes signed,
                still fetchable after a newer version becomes current.
        replaced:
          type: boolean
          description: |
            Present only on the `POST` that mints a `signing_url` (ticket #489). `true` when a
            live, unused signing link existed and this call revoked it — re-minting is allowed,
            because somebody asking for a link that works must get one, but the response now
            says what it cost so an agent can tell the human that the older link stopped
            working instead of letting them discover it.
        requested_by:
          type: [object, "null"]
          description: |
            Who this mint was attributed to, as the hosted page will report it. `null` when the
            caller did not say and no credential could be read.
          properties:
            kind: { type: string, enum: [agent, panel, api] }
            name: { type: [string, "null"] }

    MandatoRevocationLink:
      type: object
      description: |
        The cláusula 7.1 page, minted for the representante legal (ticket #465).
      properties:
        object: { type: string, const: onboarding_link }
        empresa_id: { type: string }
        step: { type: string, const: mandato_revocation }
        url:
          type: string
          format: uri
          description: |
            Returned EXACTLY ONCE — only a SHA-256 of the token is stored, so no later read can
            reproduce it. Send it to the person who signed.
        expires_at:
          type: string
          format: date-time
          description: |
            A century out. The right this link addresses does not expire, so a link that
            addresses it may not either.
        requires:
          type: array
          items: { type: string }
          description: |
            The second factor the page will ask for — `rep_legal_rut`, the RUT recorded on the
            acceptance. Named, never echoed: this response would otherwise turn the factor into
            a prompt.
        role: { type: string, const: representante_legal }

    OnboardingLinkCreateRequest:
      type: object
      required: [step]
      properties:
        step:
          type: string
          description: |
            Which hosted page the link opens: any step of THIS empresa's checklist that needs a
            human - `actor: customer` (they perform it) or `stall_actor: customer` (we perform it
            and it waits on their decision). `mandato` is deliberately absent: its link is
            single-use and comes from `POST /empresas/{empresa_id}/mandato` with an empty body.
            Read the valid values off `GET /empresas/{empresa_id}/onboarding`.
        requested_by:
          $ref: "#/components/schemas/RequestedBy"

    RequestedBy:
      type: object
      description: |
        Who is asking for this link, as YOU describe yourself (2026-09, #459). Optional, and
        rendered to the person who receives the link: "Este enlace lo pidio Claude Desktop el
        4 de septiembre" instead of "lo genero una integracion o un agente en nombre de tu
        cuenta", which was the only sentence available for every agent that has ever existed.

        A CLAIM, NOT A CREDENTIAL. It changes nothing about authorization and is never trusted for
        a decision; the credential your request actually arrived on is recorded separately. Send
        `{"kind": "agent", "name": "<your client name>"}` when you are an agent acting for a
        person.
      required: [kind]
      properties:
        kind: { type: string, enum: [agent, panel, api] }
        name:
          type: string
          maxLength: 120
          description: Your own name for yourself. Trimmed and truncated; never interpreted.

    OnboardingLink:
      type: object
      properties:
        object: { type: string, const: onboarding_link }
        empresa_id: { type: string }
        step: { type: string }
        pending_action:
          oneOf:
            - $ref: "#/components/schemas/PendingAction"
            - type: "null"
          description: |
            The step's handoff, with `action_url` set to the URL this response just minted. The
            same object `GET /empresas/{empresa_id}/onboarding` publishes for the step, plus the
            live address - so the instructions an agent relays cannot differ between the two.
        state:
          type: string
          enum: [valid, expired, revoked]
          description: |
            `revoked` outranks `expired`: a link somebody cancelled should say so rather than
            blame the clock.
        url:
          type: [string, "null"]
          format: uri
          description: |
            The address to send the representante legal, token included. Returned EXACTLY ONCE,
            at the mint: only a SHA-256 of the token is stored, so no later read can reproduce
            it.
        expires_at: { type: string, format: date-time }
        opened_at:
          type: [string, "null"]
          format: date-time
          description: |
            When a human first rendered the page. Evidence that the instruction was delivered,
            which is a different claim from "we sent it". Stamped once, not on every reload.
        acted_at: { type: [string, "null"], format: date-time }
        revoked_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        replaced:
          type: boolean
          description: |
            Whether this mint RETIRED a link that was already live (2026-09, #459).

            Re-minting revokes - a customer pressing "send it again" is asking for a link that
            works - and it used to be silent, so an agent that minted a second time was never told
            about the URL now sitting dead in a representante legal's inbox. `false` on a first
            mint. Read `link.status` on `GET .../onboarding` BEFORE minting to avoid needing this.

    GoLiveRequest:
      type: object
      properties:
        authorized:
          type: boolean
          description: |
            Present at all, this states the decision on the titular's behalf - an *atestacion*,
            behind `onboarding:attest`. `false` withdraws an authorization that has not been
            acted on yet; once the declaracion is filed the call is refused with
            `409 cumplimiento_already_filed`.

            **No default.** Omit the whole body to mint an approval link for the titular
            instead. It used to default to `true`, which meant a caller who sent no body
            silently authorized a tax consequence.

    GoLiveApproval:
      type: object
      description: |
        The enlace de aprobacion for the titular, answered by `POST .../go_live` with no body.
        Single use and seven days - it records a decision with a tax consequence, so a link that
        kept working afterwards would be an unattended second chance to change the month.
      properties:
        object: { type: string, const: go_live_approval }
        empresa_id: { type: string }
        approval_url:
          type: string
          format: uri
          description: |
            Returned EXACTLY ONCE - only a SHA-256 of its token is stored, so no later read can
            reproduce it. Re-minting revokes the previous link.
        expires_at: { type: string, format: date-time }
        role:
          type: string
          const: titular_cuenta
          description: |
            Whose decision this is. The month the IECV obligation starts in is the account
            holder's to choose - not the representante legal's, and not the agent's.

    GoLiveAuthorization:
      type: object
      properties:
        object: { type: string, const: go_live_authorization }
        empresa_id: { type: string }
        authorized: { type: boolean }
        authorized_at: { type: [string, "null"], format: date-time }
        authorized_by:
          type: [string, "null"]
          description: |
            `rep_legal` when it came from the hosted page, `account_session` from the
            dashboard, `api_key` from an integrator.
        filed_at:
          type: [string, "null"]
          format: date-time
          description: When the Declaracion de Cumplimiento was actually filed.
        accepted_at: { type: [string, "null"], format: date-time }
        sii_state:
          type: [string, "null"]
          enum: [not_ready, ready, already_declared, unknown, null]
          description: |
            What the SII last said about whether this empresa may declare at all. Published
            because "I authorized it and nothing happened" is the obvious next question.
        sii_paso:
          type: [string, "null"]
          description: The step the SII says is still open, when it says so.

    CertificationSetFile:
      type: object
      description: |
        The tenant's set de pruebas as we read it. A SUMMARY, deliberately: the door reads the
        file's skeleton - encoding, section headers, their kinds, caso headers, the atencion -
        and the round re-parses it in full when it builds. So `problems[]` here carries
        file-level trouble only; anything about a particular caso surfaces on the round.
      properties:
        object: { type: string, const: certification_set_file }
        empresa_id: { type: string }
        atencion:
          type: [string, "null"]
          description: The SII's own identifier for this set - the first numbered section's.
        encoding:
          type: [string, "null"]
          enum: [iso-8859-1, utf-8, null]
          description: |
            What the bytes actually were. ISO-8859-1 is what the SII serves; a file somebody
            re-saved out of an editor is detected rather than mangled.
        sections:
          type: array
          description: |
            On the upload, the whole table. On the read, the section KINDS only - which is
            what decides the sets this empresa must pass.
          items:
            type: object
            properties:
              title: { type: string }
              kind:
                type: string
                enum: [basico, exenta, guia, libro_guias, libro_ventas, libro_compras, boleta, unknown]
              atencion: { type: [string, "null"] }
              casos: { type: integer }
        casos: { type: integer }
        problems:
          type: array
          items: { type: string, enum: [empty_file, no_sections, no_casos, unknown_section] }
        ingested_via: { type: [string, "null"], enum: [api, dashboard, null] }
        sha256:
          type: [string, "null"]
          description: The bytes' identity. The storage path is private.
        bytes: { type: [integer, "null"] }
        captured_at: { type: string, format: date-time }
        stored:
          type: boolean
          description: |
            False when the row was written and the file itself could not be kept. Said out
            loud because the round reads the BYTES and would find nothing.

    CertificationInputs:
      type: object
      description: |
        Everything a round needs that is neither in the set file nor on the empresa. The slot
        names are the ENGINE's and are never renamed for presentation: what you store here is
        literally what the builder reads, and the round's own problems speak in these words.
        An unrecognised key is refused rather than ignored.
      properties:
        receptor:
          type: [object, "null"]
          description: |
            The party the test documents are issued to. `giro` is measured against
            `GiroRecep` (40), which is shorter than `GiroEmis` (80) - an emisor's own name of
            trade is frequently illegal in this slot.
          properties:
            rut: { type: string }
            razonSocial: { type: string }
            giro: { type: string }
            direccion: { type: string }
            comuna: { type: string }
            ciudad: { type: string }
        giro_recep_emisor:
          type: [string, "null"]
          maxLength: 40
          description: |
            A ≤40-character abbreviation of the EMISOR's own giro, for the ONE place a
            traslado interno guia writes it back: the receptor is the emisor itself
            (a traslado interno moves goods between the empresa's own premises), so its giro
            is what would be written into `GiroRecep` - which caps at 40 where the emisor's
            own `GiroEmis` allows 80.

            Absent, and the emisor's giro fits within 40 characters anyway: written verbatim,
            same as always. Absent, and it does not fit: `GiroRecep` is optional in the
            schema, so the element is OMITTED rather than truncated, and the round raises
            `giro_recep_omitted` so the omission is visible rather than silent - this is the
            exact reparo the SII drew over the pilot's own 65-character giro. Present: written
            verbatim, and refused - never truncated - if it does not fit either.
        text_correction:
          type: [object, "null"]
          description: |
            The CodRef-2 correction. Written as "Dice: {says} y debe decir: {shouldSay}" into
            `RazonRef`, which caps at 90 - 21 of them are the scaffolding, so the two halves
            share 69. Measured on the composition, and never truncated for you.
          properties:
            says: { type: string }
            shouldSay: { type: string }
        proveedores:
          type: [object, "null"]
          description: |
            Who issued each purchase, keyed `<tipoDte>:<folio>` - the two things the set
            prints for every row. A RUT whose verifier digit fails modulo 11 is one of the two
            documented causes of an LRH "Descuadrado", found after the folios are spent.
            `razonSocial` is measured against the LIBRO's `RznSoc` (50), not the DTE's 100.
          additionalProperties:
            type: object
            properties:
              rut: { type: string }
              razonSocial: { type: string }
        iva_no_rec_codes:
          type: [object, "null"]
          description: Why a purchase loses its credito. LibroCV_v10.xsd enumerates 1,2,3,4,9.
          additionalProperties: { type: integer, enum: [1, 2, 3, 4, 9] }
        libro_treatments:
          type: [object, "null"]
          description: |
            The IVA treatment of a purchase the set does not classify. "unknown" is the
            planner's word for "the set said nothing" and "exento_sin_iva" is arithmetic, so
            neither is an input.
          additionalProperties:
            type: string
            enum: [credito, iva_uso_comun, iva_no_recuperable, retencion_total, sin_derecho_credito]
        libro_guias:
          type: [object, "null"]
          properties:
            folio_notificacion:
              type: integer
              default: 3
              description: |
                PREFILLED at 3 - the number continuing the 1 and 2 the libro de ventas and the
                libro de compras carry. Mandatory in LibroGuia_v10.xsd, so it cannot be
                omitted; a round that uses the default raises
                `libro_guias_folio_notificacion_defaulted` rather than passing in silence.
            facturas:
              type: object
              description: |
                The factura that invoiced a guia the set marks "facturada", by the guia's caso
                id. LibroGuia_v10.xsd has no "facturada" flag - the TpoDocRef/FolioDocRef/
                FchDocRef triple IS the statement - and the set never names the factura.
              additionalProperties:
                type: object
                properties:
                  tipoDte: { type: integer }
                  folio: { type: integer }
                  fecha: { type: string, format: date }
        factor_proporcionalidad:
          type: [number, "null"]
          minimum: 0
          maximum: 1
          description: |
            The set prints it as "EL FACTOR DE PROPORCIONALIDAD DEL IVA ES DE 0.60". It
            decides how much credito is claimed, so it is never guessed.
        libro_boleta_folio_notificacion:
          type: [integer, "null"]
          description: |
            The SII-issued "Folio de la Notificacion con que se Solicita el Libro Especial".
            NO DEFAULT, and there will not be one - nothing can derive it, and a boleta round
            refuses without it.
        boleta_unidades:
          type: [object, "null"]
          description: |
            `UnmdItem` by `<casoId>:<nroLinDet>`, for the one thing a set can demand that its
            own schema forbids - a unidad longer than four characters. Refused, never
            truncated: "mens" is not a unit.
          additionalProperties: { type: string, maxLength: 4 }
        boleta_muestras:
          type: [array, "null"]
          description: |
            The ten everyday boletas the SII's boleta review asks for, describing what this
            empresa actually sells at prices it actually charges. Fewer than ten is an
            incomplete request and is refused; more is fine.
          items:
            type: object
            properties:
              nombre: { type: string }
              monto:
                type: integer
                description: The GROSS total in pesos - the number the customer would pay.
              unidad: { type: string, maxLength: 4 }
        permiso_usuario_rut:
          type: [string, "null"]
          description: |
            The natural person the permisos probe asks "Mantencion de usuarios" about, when
            the custodied certificate encodes no RUT of its own.

            THE ONLY SLOT HERE THAT IS NOT A DOCUMENT FACT, and the only one no builder reads.
            Estado 106 "Usuario sin permiso de envio" is about the RUT the SII token was
            minted for, so the probe reads it out of the certificate first and falls back to
            this. Absence does NOT refuse a round: the round still builds and signs, and it is
            the permisos gate that parks, reporting `no_target` with the remedy attached.

            Validated modulo 11 like every other RUT here. `null` clears it. Never the
            empresa's own RUT - that table lists natural persons, so an empresa RUT would find
            nobody and report the sender as unlisted.

    Certification:
      type: object
      properties:
        object: { type: string, const: certification }
        empresa_id: { type: string }
        round:
          type: integer
          description: |
            The round the last run built. 1 with a null `last_run_at` means "we have not
            attempted", which is not the same as "we are on the first attempt".
        periodo: { type: [string, "null"] }
        set_file_ready: { type: boolean }
        last_run_at: { type: [string, "null"], format: date-time }
        detail: { type: [string, "null"] }
        set_file: { $ref: "#/components/schemas/CertificationSetFile" }
        inputs: { $ref: "#/components/schemas/CertificationInputs" }
        slots:
          type: array
          description: |
            The slot vocabulary as the engine knows it, with what this empresa has supplied.
            Published so a form cannot offer a slot the engine ignores nor hide one it needs.
          items:
            type: object
            properties:
              key: { type: string }
              shape: { type: string, enum: [object, row_map, line_map, caso_map, number, rut, list] }
              lane: { type: string, enum: [set, boleta] }
              prefilled: { type: [integer, "null"] }
              refuses_with: { type: [string, "null"] }
              notice_when_defaulted: { type: [string, "null"] }
              supplied: { type: boolean }
        submittable:
          type: [boolean, "null"]
          description: Null until a build has happened at all - not the same as false.
        problems:
          type: array
          items: { $ref: "#/components/schemas/CertificationFinding" }
        notices:
          type: array
          description: |
            What the build DECIDED rather than refused. A notice never blocks a round; it is
            here because we chose it on the empresa's behalf and a rendicion has to be able to
            show that.
          items: { $ref: "#/components/schemas/CertificationFinding" }
        envios:
          type: array
          items: { $ref: "#/components/schemas/CertificationEnvio" }
        boleta_round:
          type: [object, "null"]
          description: The boleta lane's own checkpoint, when this empresa has one.

    CertificationFinding:
      type: object
      properties:
        code: { type: string }
        ref: { type: [string, "null"], description: The caso or libro row it belongs to. }
        message:
          type: [string, "null"]
          description: |
            Null when the build record could not be read. The code is always present; a null
            message means "we know this happened and could not read the sentence about it".

    CertificationEnvio:
      type: object
      properties:
        object: { type: string, const: certification_envio }
        set_kind: { type: string }
        round: { type: integer }
        periodo: { type: [string, "null"] }
        channel: { type: string, enum: [dte, boleta] }
        status: { type: string, enum: [built, blocked, uploaded, failed] }
        driver: { type: string }
        dry_run:
          type: boolean
          description: True means the envelope was built and NOT sent.
        track_id: { type: [string, "null"] }
        estado:
          type: [string, "null"]
          description: |
            The ENVIO estado. `SOK` here means "envio recibido" - arrived, not judged. The
            set's own verdict is `verdict` below, where `SOK` means approved.
        estado_glosa: { type: [string, "null"] }
        envio_outcome: { type: [string, "null"], enum: [accepted, rejected, pending, unknown, null] }
        folios:
          type: object
          description: |
            Every folio this round spent, per tipo. Irreversible - a folio the SII has judged
            can never carry another document.
          additionalProperties:
            type: array
            items: { type: integer }
        caso_ids: { type: array, items: { type: string } }
        envelope_sha256: { type: [string, "null"] }
        envelope_bytes: { type: [integer, "null"] }
        declared_at: { type: [string, "null"], format: date-time }
        declare_verdict:
          type: [string, "null"]
          enum: [declared, already_declared, refused, unreadable, null]
          description: |
            The Declarar Avance answer. `already_declared` is a SUCCESS: reading a repeat as a
            failure would rebuild a round the SII already accepted, burning folios to fix
            nothing.
        created_at: { type: string, format: date-time }
        verdict:
          type: [object, "null"]
          description: |
            The SET verdict, which arrives after the SII reviews and may never arrive at all.
            Null is "not reviewed", never "rejected".
          properties:
            outcome: { type: string, enum: [accepted, rejected, pending, unknown] }
            estado: { type: [string, "null"] }
            reparos: { type: integer }
            captured_at: { type: string, format: date-time }
    OnboardingEvidence:
      type: object
      description: |
        One act the onboarding pipeline performed on the empresa's behalf - the consultable
        half of the mandato's clausula 4.4 (rendicion de cuentas).
      properties:
        object: { type: string, const: onboarding_evidence }
        id: { type: string }
        empresa_id: { type: string }
        step: { type: string }
        tipo_dte:
          type: [integer, "null"]
          description: Set on the four certification gates, which the SII evaluates per type.
        kind:
          type: string
          description: |
            What the artifact is. Known kinds include `probe_html`, `envio_xml`, `track_id`,
            `verdict`, `declare_receipt`, `caf_xml`, `set_file`, `set_envio`, `set_verdict`,
            `rep_legal_link_issued`, `rep_legal_link_opened`, `mandato_acceptance`,
            `postulacion_attestation`, `go_live_authorized`, `go_live_withdrawn`. The list is
            open: a later wave adds kinds without a breaking change.
        captured_at: { type: string, format: date-time }
        retention_until:
          type: string
          format: date-time
          description: |
            Six years from the act. Codigo Tributario prescripcion extraordinaria plus its
            conservation duty, which is also the retention purpose under Ley 21.719. On
            revocation or traspaso the record freezes read-only and is offered as an export;
            it is never purged early.
        artifact:
          type: [object, "null"]
          description: Present when bytes were stored. The digest is what makes it checkable.
          properties:
            sha256: { type: string }
            bytes: { type: [integer, "null"] }
            content_type: { type: [string, "null"] }
        detail:
          type: object
          additionalProperties: true

    OnboardingStatus:
      type: string
      enum:
        - draft
        - mandato_pending
        - mandato_signed
        - certificate_pending
        - certificate_verified
        - sii_postulacion_pending
        - certification_in_progress
        - cumplimiento_pending
        - resolucion_pending
        - folios_pending
        - emitting
        - action_required
        - blocked
        - suspended

    Onboarding:
      type: object
      properties:
        object: { type: string, const: onboarding }
        empresa_id: { type: string }
        mode:
          oneOf:
            - $ref: "#/components/schemas/Mode"
            - type: "null"
          description: |
            `test` or `live` — which world this empresa is in (#418). A sandbox empresa is born
            `emitting` with a checklist of `skipped` steps, and a live one at the same `emitting`
            walked eleven of them; the two bodies are otherwise identical, so a screenshot, a log
            line, or a webhook payload could not tell which one produced it. `null` when the
            caller building this body did not say — never a guess, because `live` is the answer
            that gets acted on.
        status: { $ref: "#/components/schemas/OnboardingStatus" }
        progress: { type: number, minimum: 0, maximum: 1 }
        blocked_on:
          type: [string, "null"]
          description: |
            The step a human (or a failed requirement) is holding up. Always names a step
            present in `steps[]`. When `status` is `action_required` that step always carries a
            `pending_action` — which is where its `link.mint` lives, and where its `action_url`
            lives when one is live; when `status` is `blocked` it carries a `blocked_reason`
            instead, because a blocked empresa has nothing to click — a fact at the SII has to
            change.
        pending_action:
          oneOf:
            - $ref: "#/components/schemas/PendingAction"
            - type: "null"
          description: |
            The handoff for the step `blocked_on` names, hoisted so "what is blocking me and what
            do I tell my human" is one field read. `null` when the blocking step is OURS or the
            SII's, which is the honest answer and a different thing from "there is nothing to
            do": it means wait, not act.
        estimated_ready_at:
          type: [string, "null"]
          format: date
          description: |
            Today plus the median business days the remaining steps take (Mon–Fri, no feriado
            calendar), each one counted as `steps[].eta_business_days` publishes it — so a step
            that still waits on a person of ours (`execution: operator`) contributes the sitting
            floor rather than its queue estimate (2026-09-07, #556). An estimate, not a promise:
            the rep-legal decisions are wall-clock unbounded, and it counts OUR time only — not
            what the SII takes to answer the postulación or to issue the resolución. `null` once
            `emitting` or `suspended`, while `blocked`, and while `draft`.
        requirements:
          type: array
          description: |
            What the SII requires of the contribuyente before it will authorize an emisor,
            as we last read it from the SII. EMPTY until the probe has run once — an empty
            list means "we have not looked", not "there is nothing to do". `unknown` means we
            looked and could not read the answer; it is never treated as `met`.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                  - inicio_actividades
                  - verificacion_actividades
                  - representante_legal_certificate
                  - usuario_administrador
                  - sin_situaciones_pendientes
              label:
                type: string
                description: Spanish. The requirement as the person who has to satisfy it reads it.
              status: { type: string, enum: [met, unmet, unknown] }
        steps:
          type: array
          description: |
            The checklist, in order. `habilitacion` is present only for empresas whose
            `doc_types` include a boleta type (39 or 41) — render what you are given rather
            than assuming a length. The four certification steps are evaluated PER DOCUMENT
            TYPE by the SII; each appears once here with the worst status across the declared
            types, and `certifications[]` breaks the same facts out per type.

            **WHICH CHECKLIST YOU GET FOLLOWS `certification_track`, NOT `emission_channel`**
            (#474, corrected 2026-09-04 — this paragraph named the channel and the code has
            always read the track). An empresa whose `certification_track` is `sfg` or `null`
            returns a SHORT, PARALLEL checklist — `mandato`, `credential`,
            `clave_centralizado`, `usuario_autorizado`, `emisor_verificado`, `puesta_en_vivo`,
            `emitting` — because a Sistema de Facturación Gratuito empresa is already a
            production emisor and never runs postulación, certification, cumplimiento,
            resolución or timbraje. For it `certifications[]` is empty.

            The two fields can legitimately disagree (ADR-0010 §1): the pilot emits through the
            free portal while certifying for mercado. A new empresa's track is set to its
            channel at creation; `PATCH /v1/empresas/{empresa_id}` changes each independently,
            and changing the channel alone deliberately leaves the track where it is. Render
            the steps you are given; do not assume which set you will receive.

            Two of those seven are the facts that ACTUALLY gate emission and used to appear on
            no checklist at all. `clave_centralizado` is the empresa's Sistema de Facturación
            Gratuito password: Portal MIPYME asks for it again at the irreversible step of every
            emission, so without it nothing can be issued even with the delegation live and a
            certificate in custody. `puesta_en_vivo` is OURS — the owner-present canary and the
            per-empresa enablement — and it is `actor: facturia`, rendered as a waiting state
            with an estimate, never as something a customer must do.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                  - mandato
                  - certificate
                  - sii_postulacion
                  - set_de_pruebas
                  - simulacion
                  - intercambio
                  - muestras_impresas
                  - cumplimiento
                  - resolucion
                  - habilitacion
                  - folios
                  # emission_channel=sfg only (the short parallel catalog):
                  - credential
                  - clave_centralizado
                  - usuario_autorizado
                  - emisor_verificado
                  - puesta_en_vivo
                  - emitting
              label:
                type: string
                description: Spanish. The step's name as a person reads it on a checklist.
              status:
                type: string
                enum: [pending, in_progress, action_required, completed, failed, skipped]
                description: |
                  `failed` means something we run gave up after its retries. It is ours to fix
                  and never moves the empresa to `blocked`. `skipped` means the step was never
                  required — most often because the RUT was already an authorized emisor
                  electrónico, so there was nothing to certify.
              actor:
                type: string
                enum: [facturia, customer, sii]
                description: |
                  `customer` marks a step a PERSON PERFORMS: the mandato, the certificate
                  upload, and on the SFG catalog the `clave_centralizado` and the
                  usuario-autorizado grant. The certificate upload and the clave happen in OUR
                  app rather than at the SII, and are the two an agent can complete on its
                  principal's behalf (see `pending_action.agent_actions`).

                  `facturia` DOES NOT MEAN «nothing for anybody to do». Three mercado steps are
                  ours and still cannot finish without a person: `sii_postulacion`,
                  `cumplimiento` and `habilitacion`. We perform each of them at the SII with the
                  custodied certificate — which is the representante legal's own — after that
                  person approves on a hosted page, and the field that says a decision is
                  pending is `pending_action.approval`, never `actor`. Branch on the handoff.

                  `puesta_en_vivo` is the opposite case and the one to watch: everything the
                  customer owes is done and the canary is ours, so it carries no
                  `pending_action` at all.
              help:
                type: string
                description: |
                  Spanish. One or two sentences explaining the step to the person who owns it —
                  written for them, not for you. Relay it verbatim rather than paraphrasing:
                  several of these carry the legal reason an act cannot be delegated.
              action_url:
                type: [string, "null"]
                format: uri
                description: |
                  A hosted page you can open RIGHT NOW, or `null`. Non-null only in the response
                  that minted the link (`POST .../onboarding/links`, or `POST .../mandato` with an
                  empty body): the pages are reached by a capability token stored only as a hash,
                  so a read cannot reproduce the address. On `GET .../onboarding` it is `null`.

                  It used to be non-null on every step at `action_required`, built from the step
                  key — and that URL is rejected by the page it addresses. `pending_action.link`
                  is the field to use: it names the call that mints a URL that works.
              link:
                type: [object, "null"]
                description: |
                  WHAT THE HOSTED LINK FOR *THIS* STEP IS DOING (contract C3, 2026-09).

                  Exactly the object documented at `PendingAction.link` — `status`, `minted_at`,
                  `expires_at`, `opened_at`, `requested_by`, `mint` (null while a link is `live`
                  or `opened`), `ttl_seconds`, `reusable` — published on EVERY step a person can
                  be sent to, not only on the one `blocked_on` names. `null` on the steps that
                  are ours, where there is nobody to send.

                  It was published only inside `pending_action`, which exists on one step at a
                  time, so an agent that had minted links for two steps could read the state of
                  one of them. That is the exact question this contract exists to answer, because
                  minting again REVOKES the URL somebody may already be holding.
              pending_action:
                oneOf:
                  - $ref: "#/components/schemas/PendingAction"
                  - type: "null"
                description: |
                  The §7.4 invariant, in the field that can actually keep it: onboarding never
                  asks for a human without telling them what to DO and how to reach the page.
                  Non-null on every `customer` step that owes an action — the one at
                  `action_required` and the one `blocked_on` names — and `null` on every step
                  that owes none, and on the steps that are ours: instructions to a person about
                  work we run unattended would be worse than silence.
              completed_at: { type: [string, "null"], format: date-time }
              evidence_source:
                type: string
                enum: [sii_probe, own_record, no_producer, attested]
                description: |
                  How this step's completion becomes known (#395), so a caller never has to guess
                  whether anything is actually watching.

                  `sii_probe` — we re-read the SII and the step advances on what it says.
                  `own_record` — the act lands in our own tables, so we see it as it happens.
                  `no_producer` — NOTHING IN THIS BUILD OBSERVES IT (`habilitacion` today, the
                  boleta habilitación at `certBolElectDteInternet`, an uncaptured SII SPA). Use
                  `POST .../onboarding/steps/{step}/attest` — never a plain `recheck`, which
                  cannot confirm what nothing observes.
                  `attested` — a `no_producer` step that reached `completed` through exactly that
                  atestación. The only way such a step can be `completed` in this build.
              execution:
                type: string
                enum: [automatic, operator]
                description: |
                  HOW A STEP OF OURS ACTUALLY RUNS (2026-09, #487; derived per deployment
                  since 2026-09-07, #556).

                  `actor: facturia` says who owns the step and nothing about whether anybody is
                  watching. Seven of ours reach the SII only when a fence is open — the
                  postulación, the `intercambio` uploads, the `muestras_impresas` filing, the
                  Declaración de Cumplimiento, the boleta `habilitacion`, and the «Avanzar
                  Siguiente Paso» that closes `set_de_pruebas` and `simulacion` (one fence, one
                  act, both pasos) — and while a fence is shut, that step is a person of ours at
                  a keyboard. The first five published `in_progress` indistinguishably from a job
                  on a schedule; the stage-advance pair was worse and published `automatic`
                  outright while a person clicked in a browser. Either way it is the difference
                  between "this finishes overnight" and "this finishes when somebody is at a
                  desk".

                  **THIS IS NOT A CONSTANT OF THE PRODUCT. It is read per deployment**, from the
                  same environment variable the executor reads, so the SAME step can answer
                  `operator` on one deployment and `automatic` on another, and can change from
                  one day to the next without a new API version. Branch on the value you were
                  given; never cache it as a fact about a step key.

                  `automatic` on every step with no such fence, including the customer's, where
                  it means "nothing of OURS is pending on it". The authority for WHICH fence
                  belongs to which step is the lock register (`worker/src/onboarding/locks.ts`),
                  whose rows name the step each fence holds; a test walks it against the catalog
                  in both directions, so a fence that names a step nothing publishes as an
                  operator's fails the build, and so does the reverse.
              eta_business_days:
                type: integer
                description: |
                  This step's own p50, in business days (2026-09, #487). `estimated_ready_at` at
                  the top of the body is the sum over everything still to run; this is the one
                  line of it, so a checklist can put a number beside the row a person is actually
                  looking at. A p50, not a promise.

                  **A STEP READING `execution: operator` NEVER PUBLISHES LESS THAN 2** (2026-09-07,
                  #556). The catalog's base numbers were decomposed from a queue — 0 means "the
                  SII answers this envelope in minutes" — and none of them describes a person of
                  ours finding an hour for a sitting. So while a step waits on one, its p50 is
                  floored at two business days, and it drops back to the base the moment that
                  step's fence opens. Read it off the wire per step; it moves with `execution`.
              next_poll_at:
                type: [string, "null"]
                format: date-time
                description: |
                  WHEN WE LOOK AT THE SII AGAIN FOR THIS STEP (2026-09-07, #563), or `null` when
                  no read is scheduled.

                  Present on every step, and it is the field that makes an `in_progress` step
                  honest. A step whose last probe learnt nothing — the SII did not answer, or
                  answered "granted, not yet applied" — used to be indistinguishable on the wire
                  from one that is about to finish, and an agent reading the checklist reported
                  "we are doing fine" about a wait nobody had timed. Render it with
                  `blocked_reason`: "el SII no contestó; volvemos a mirar a las 13:47".

                  A SCHEDULE, not a promise: it is when the probe's own cadence falls due, and
                  the reconciler notices within about five minutes of it. `POST
                  .../onboarding/steps/{step}/recheck` asks now instead of waiting for it.
              blocked_reason:
                type: object
                description: |
                  Why this step is where it is, as structured data with a `code` and a Spanish
                  `message`. On the steps only a representante legal may perform it also carries
                  a `packet` — the SII page to open, the empresa RUT, a checklist, and the
                  reminder that the step needs THEIR certificate, not the one you custodied with
                  us. Additional keys may appear; branch on `code`.

                  ON `failed`, `action_required` **AND `in_progress`** (2026-09-07, #563). This
                  said "failed or action_required", and the third case is the one a caller most
                  needs: a step that is running publishes the LAST PROBE OUTCOME while that probe
                  did not settle it — `sii_unreachable` (no answer from the SII), `sii_propagation`
                  (the SII shows the grant and has not applied it yet), `requirement_unmet` (its
                  own user table refuses the empresa's sender; `details.key` names the permiso).
                  None of the three blocks the empresa, names it in `blocked_on` or asks for a
                  person: they carry `retriable: true` and a Spanish `hint`, and `next_poll_at`
                  beside them says when the next read is due. Absent while a running step has
                  nothing to report.
        certifications:
          type: array
          description: Doc types certify separately; boletas certify in parallel with facturas.
          items:
            type: object
            properties:
              tipo_dte: { $ref: "#/components/schemas/TipoDte" }
              status: { type: string, enum: [not_started, in_progress, certified, failed] }
              note: { type: string }
        iva_shape: { $ref: "#/components/schemas/TaxpayerIvaShape" }
        updated_at: { type: string, format: date-time }

    PendingAction:
      type: object
      description: |
        The handoff for an act that still needs a person — the OAuth-consent analogue of this
        API.

        WHY A PERSON, AND IT IS NOT WHAT THIS FIELD SAID UNTIL 2026-09-04. It said the SII
        "requires the taxpayer or their legal representative to authenticate personally, with
        THEIR OWN certificate", and that the custodied one "signs documents; it cannot
        authenticate a person". That is false, and it was the load-bearing sentence under three
        surfaces. A Chilean CA issues certificates to NATURAL PERSONS: the one in the vault is
        the representante legal's (mandato 1.3), and a TLS client-certificate session with the
        SII authenticates AS ITS HOLDER. Facturia ran an entire boleta certification with it,
        end to end, from code, on 2026-09-04.

        What remains is a LEGAL line rather than a technical one, and it is narrower. Mandato
        clausula 4.6 "Actos reservados al Cliente" reserves some acts to the customer; a few
        others are decisions only they can take because of what the act COSTS — the month an
        empresa stops being a free-system user, the moment a 24-hour SII clock starts. Those
        arrive as an `approval` block below: we perform the act with the custodied certificate,
        and a person authorises it on a hosted page of ours. The `sii_postulacion` step is
        exactly that shape now — Facturia files the postulacion, after the titular approves.

        Everything a consumer needs to relay the errand to a human is here, so no second call is
        needed and no click path has to be improvised. Customer-facing strings are in **Spanish**
        because the person who performs the act is Chilean and is frequently not the account
        holder; field names are English like everything else.

        Present on `steps[].pending_action` for any step that asks a human to act, hoisted to
        `onboarding.pending_action` for the step `blocked_on` names, and carried inside
        `error.details.pending_action` when a `/v1` write is refused because one is outstanding.
        `null` whenever the wait is ours or the SII's - which means wait, not act.
      properties:
        object: { type: string, const: pending_action }
        act:
          type: string
          description: |
            The ACT, which is not always the step. One step can park on two different errands -
            `certificate` means "buy a certificate from a CA" with no certificate on file and
            "grant «Enviar Doctos.» in Mantencion de usuarios" when the holder lacks the
            permission, which is a different portal and possibly a different person. Branch on
            this rather than on `step` when you care which.
        step: { type: string, description: The onboarding step this handoff hangs off. }
        empresa_id: { type: string }
        title: { type: string, description: "Spanish, one line: what must happen." }
        summary: { type: string, description: "Spanish, for someone who has never heard of the SII." }
        action_url:
          type: [string, "null"]
          format: uri
          description: |
            A LIVE hosted page, or `null`. The same value as the step's `action_url`.

            The hosted pages are reached by a single-use capability token that is stored only as a
            SHA-256, so no read can reproduce one: this field is non-null only in the response that
            MINTED the link (`POST /v1/empresas/{id}/onboarding/links`, or `POST /v1/empresas/{id}/mandato`
            with an empty body). Everywhere else it is `null` and `link` below says how to get one.

            It used to be a URL built from the step key and never null - and that URL is REJECTED by
            the page it addresses, which answers "Esta pagina se abre desde el enlace que te
            mandamos" without a `?token=`. Relaying it sent a representante legal to a page telling
            them to ask somebody for a link. Do not reconstruct it; mint it.
        link:
          type: object
          description: |
            What the hosted link for this step IS DOING, and how to obtain one. Always present.

            The refusal points at the endpoint that fixes it, the way RFC 9728's `resource_metadata`
            points a `401` at the consent flow. Issue `mint` exactly as given; the response carries
            the URL once.

            **`status` first, then `mint` (2026-09, #459).** `action_url` is `null` on every read by
            construction - the token is stored only as a SHA-256 - so an agent re-reading this body
            saw a null URL beside a mint recipe and minted again. For `mandato` that REVOKES the link
            the representante legal already has in their inbox; for any other step it retires a URL
            somebody may be about to open. `mint` is therefore `null` exactly while `status` is
            `live` or `opened`: there is already a link, and minting would destroy it. Branch on
            `status`, and treat a `null` `mint` as "you already have one", never as an error.
          properties:
            status:
              type: string
              enum: [none, live, opened, consumed, expired, revoked]
              description: |
                - `none` - nobody has minted one. Mint.
                - `live` - minted, nobody has opened it. Re-send the URL you were given, or wait.
                - `opened` - somebody has the page open. Minting now breaks their tab.
                - `consumed` - somebody acted on it. The step moves on its own from here.
                - `expired` - the clock ran out. Mint a fresh one.
                - `revoked` - it was cancelled or superseded. Mint a fresh one.
            minted_at:
              type: [string, "null"]
              format: date-time
            expires_at:
              type: [string, "null"]
              format: date-time
            opened_at:
              type: [string, "null"]
              format: date-time
              description: When somebody first opened the page. Never moves on a reload, and always `null` for the mandato, whose page records none.
            requested_by:
              type: [object, "null"]
              description: |
                Who asked for the link, AS THEY DESCRIBED THEMSELVES. Optional on the mint
                (`POST .../onboarding/links` and `POST .../mandato` both accept
                `requested_by: {kind, name}`), and rendered to the person who receives the link -
                "Este enlace lo pidio Claude Desktop" instead of "lo genero una integracion".

                It is a CLAIM, never an authentication fact: the credential the mint actually
                arrived on is recorded separately and is not published here.
              properties:
                kind: { type: string, enum: [agent, panel, api] }
                name: { type: [string, "null"] }
            mint:
              type: [object, "null"]
              description: |
                How to mint one - `null` while `status` is `live` or `opened`, because minting there
                REPLACES a link somebody is already holding.
              properties:
                method: { type: string, const: POST }
                url:
                  type: string
                  format: uri
                  description: Absolute, so this object alone is enough to issue the request.
                body:
                  type: object
                  additionalProperties: true
                  description: "`{ \"step\": \"...\" }` for the hosted links endpoint; `{}` for the mandato."
            ttl_seconds:
              type: integer
              description: How long the minted link lives. 7 days for the mandato, 30 for the rest.
            reusable:
              type: boolean
              description: |
                `false` for the mandato - its token is single use, because it produces a legal
                instrument and a second click must be told "ya se firmo" rather than offered a
                second signature. `true` for every other step: those produce no instrument of ours,
                so the link is a durable address for a checklist a person may re-read before acting.
        agent_actions:
          type: array
          description: |
            What YOU can do here, as opposed to the human. Always present; usually empty,
            because these acts happen at the SII under a person's own certificate and a list
            full of hopeful-looking calls would be worse than none.

            The `cumplimiento` handoff is the one that carries a real entry: `POST
            .../onboarding/go_live` with an empty body, which mints the approval link for the
            titular. That call used to appear only as Spanish prose inside a
            `prerequisites[].label` - which this object tells you to relay VERBATIM - so agents
            read it out to a representante legal who has no API key.
          items:
            type: object
            properties:
              method: { type: string, const: POST }
              url: { type: string, format: uri, description: Absolute; issue it as given. }
              body:
                type: object
                additionalProperties: true
              label: { type: string, description: "Spanish, one line: what this call does." }
              when:
                type: string
                description: "Spanish, one line: when to make it, relative to the human's act."
        sii_url:
          type: [string, "null"]
          format: uri
          description: |
            The SII's own page, when we can name one that will not 404. `null` when we were not
            told which ambiente the empresa runs in - maullin is certification and palena is
            production, and a representante legal sent to the wrong one either bounces or
            rehearses the real thing, so we say nothing rather than guess.
        performed_by:
          type: object
          description: WHO, specifically. Never "the customer".
          properties:
            role:
              type: string
              enum: [representante_legal, usuario_administrador, titular_certificado, titular_cuenta]
            label: { type: string, description: "Spanish. The person, not the party." }
            why_not_delegable:
              type: string
              description: |
                Spanish, one sentence. Relay it: "the law requires you personally" persuades a
                reluctant signatory far better than "the software cannot do this".
            legal_basis:
              type: array
              items: { type: string }
              description: The citations that sentence stands on. Verbatim and checkable.
        prerequisites:
          type: array
          description: What to have in hand BEFORE opening the page.
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
        instructions:
          type: array
          description: |
            Ordered and concrete, contiguous from 1. Relay them VERBATIM - do not paraphrase and
            do not add steps. See `evidence` for how much these can be trusted.
          items:
            type: object
            properties:
              order: { type: integer }
              text: { type: string }
        consequences:
          type: array
          items: { type: string }
          description: |
            Spanish. What performing the act sets in motion, where that is not obvious and
            getting it wrong is expensive - the declaracion de cumplimiento starts the empresa's
            Informacion Electronica de Compras y Ventas obligation from the month it is made.
            Empty for acts with no such consequence.
        verification:
          type: object
          description: How we find out it happened. Never "the customer tells us".
          properties:
            method:
              type: string
              enum: [sii_probe, own_record, no_producer]
              description: |
                `sii_probe` - we re-read the SII and the step advances on what it says.
                `own_record` - the act lands in our own tables, so we see it as it happens.
                `no_producer` - NOTHING IN THIS BUILD OBSERVES IT. Named rather than hidden: a
                step whose completion nobody produces would otherwise sit `action_required`
                forever while everyone assumes something is watching. Do not tell a user we are
                watching for one of these.
            description: { type: string }
            recheck_url:
              type: [string, "null"]
              deprecated: true
              description: |
                The recheck endpoint for this step, as `POST <path>` — a method glued to a relative
                path a caller has to parse apart and prefix a host onto. **Deprecated — use
                `recheck` instead**, which names the identical endpoint as `{method,url}`, absolute
                (#419). Null when there is nothing to ask. Kept, and still populated, for existing
                callers.
            recheck:
              type: [object, "null"]
              description: |
                The same endpoint as `recheck_url`, structured (#419, contract (d)) — the shape
                `link.mint` already ships. `null` under the exact same condition as `recheck_url`:
                nothing to ask (`method` above is `no_producer`).
              properties:
                method: { type: string, const: POST }
                url:
                  type: string
                  format: uri
                  description: Absolute, so this object alone is enough to issue the request.
            sii_latency: { type: string, description: Spanish. Roughly how long the SII takes to show it. }
        estimated_minutes: { type: integer, description: "The human's time, at p50." }
        next: { type: string, description: "Spanish. What happens after, and whether they must tell us." }
        evidence:
          type: string
          enum: [captured, documented, entry_point_only]
          description: |
            How well-evidenced the click path is, published because a confidently wrong
            instruction is worse than an honest vague one.

            `captured` - we have driven this exact chain and the pages are fixtures in our
            repository; the instructions name real screens. `documented` - the SII's own
            documentation names the path and we have not walked it; you get the menu route, not
            a field-by-field script. `entry_point_only` - we can name the door and nothing behind
            it.
        caveat:
          type: [string, "null"]
          description: |
            Spanish. Non-null whenever `evidence` is not `captured`, saying exactly what we are
            unsure about. RELAY IT. Dropping it turns "the exact screen may differ" into a
            promise we cannot keep.
        copy:
          type: [object, "null"]
          additionalProperties: { type: string }
          description: |
            Copy by phase, for the one act whose hosted page and dashboard wizard used to write
            their own prose and drifted apart over who declares (#393). `null` for every act
            except `cumplimiento`, which publishes `before_authorization`, `after_authorization`
            and `after_filed`.

            THIS IS THE SINGLE AUTHOR. Both surfaces render this rather than keeping their own
            version — which is exactly how one used to say «cuando el SII abra el trámite lo
            hacemos nosotros» while this very object, right beside it, said the representante
            legal declares in person (cláusula 4.6 b). It says the truth of this build: Facturia
            does not declare (`ALLOW_LIVE_CUMPLIMIENTO_WRITES` is `false`, structurally); once
            the titular approves the month, the copy says to declare in the SII with the packet —
            never that Facturia will do it.

    OnboardingRecheck:
      type: object
      description: |
        The answer to a recheck. It records that you asked and says what we queued; it verifies
        nothing by itself.
      properties:
        object: { type: string, const: onboarding_recheck }
        empresa_id: { type: string }
        step: { type: string }
        status:
          type: string
          enum: [pending, in_progress, action_required, completed, failed, skipped]
          description: |
            The step's status as it stood when you called, read BEFORE anything was enqueued and
            returned UNCHANGED. Reading back the same status you posted against is the endpoint
            working correctly.
        accepted: { type: boolean, description: The claim was recorded and the producers queued. }
        verified:
          type: boolean
          description: |
            Always `false`. Verification is what the producer does, later, against the SII. This
            field exists so no caller can mistake acceptance for confirmation.
        evidence_source:
          type: string
          enum: [sii_probe, own_record, no_producer, sandbox]
          description: How this step can be confirmed. See `PendingAction.verification.method`.
        contact:
          type: [object, "null"]
          description: |
            WHERE TO GO when `evidence_source: 'no_producer'` means this very call could not
            confirm anything (#395) — `null` for every other `evidence_source`. Named as data
            rather than left in `message`'s prose, so a caller holding only this response can
            still act on it.
          properties:
            channel: { type: string, enum: [attest] }
            api:
              type: string
              deprecated: true
              description: |
                `POST /v1/empresas/{id}/onboarding/steps/{step}/attest` for this step.

                DEPRECATED (2026-09, #497): a method glued to the front of a relative path, so a
                caller has to split it and prefix a host it knows separately - while
                `pending_action.link.mint` two fields away needs neither. Use `call`. Still
                populated, unchanged.
            call:
              allOf:
                - $ref: "#/components/schemas/ApiCall"
                - description: The same endpoint as `api`, in the one shape #419 fixed on.
        queued:
          type: array
          items: { type: string }
          description: Job types this call enqueued.
        already_queued:
          type: array
          items: { type: string }
          description: |
            Job types already scheduled for this five-minute window. Not a failure - "we are
            already about to look" is a true answer to "please look".
        message: { type: string, description: "Spanish, for the person who made the claim." }
        pending_action:
          oneOf:
            - $ref: "#/components/schemas/PendingAction"
            - type: "null"
          description: The handoff again, so a caller who got the errand wrong can re-read it.
        onboarding_url:
          type: string
          deprecated: true
          description: |
            Relative. DEPRECATED (2026-09, #497) in favour of `next`, which is the same read as
            `{method, url}` absolute - no method to split off the front, no host to prefix by
            hand. Still populated, unchanged.
        next:
          $ref: "#/components/schemas/ApiCall"
        requested_at: { type: string, format: date-time }

    OnboardingAttestation:
      type: object
      description: |
        The answer to an atestación (#395). Unlike a recheck, this call DOES write a fact — one
        `sii_onboarding_evidence` row — for the one shape of step (`evidence_source: no_producer`)
        this build cannot otherwise ever observe.
      properties:
        object: { type: string, const: onboarding_attestation }
        empresa_id: { type: string }
        step: { type: string }
        attested_by:
          type: string
          description: Who told us this happened — a name, or the representante legal's RUT.
        note: { type: [string, "null"] }
        attested_at: { type: string, format: date-time }
        accepted: { type: boolean }
        evidence_source: { type: string, enum: [attested] }
        queued:
          type: boolean
          description: Whether the fold tick was enqueued. `false` only on an insert failure; the hourly sweep still catches it.
        message: { type: string, description: Spanish. }
        onboarding_url:
          type: string
          deprecated: true
          description: |
            Relative. DEPRECATED (2026-09, #497) in favour of `next`, which is the same read as
            `{method, url}` absolute - no method to split off the front, no host to prefix by
            hand. Still populated, unchanged.
        next:
          $ref: "#/components/schemas/ApiCall"

    ApiCall:
      type: object
      description: |
        ONE SHAPE FOR "THE NEXT CALL" (#419, #497). A method plus an ABSOLUTE url, optionally
        with the body and headers to send, so an object carrying one is enough to issue the
        request — no method parsed off the front of a string, no host prefixed by hand, no
        relative path resolved against a base the caller has to know.

        Five different spellings of this idea shipped before it was named. Where an older field
        published one of them (`capabilities.next.api`, `contact.api`, `onboarding_url`), the
        old field is KEPT and populated and an `ApiCall` sibling is added beside it — additive,
        per rule #419 — and the prose on each says which one to branch on.
      required: [method, url]
      properties:
        method:
          type: string
          enum: [GET, POST, PATCH, PUT, DELETE]
        url:
          type: string
          format: uri
          description: Absolute. Never a path.
        body:
          type: object
          additionalProperties: true
          description: The exact body to send, when the call takes one.
        headers:
          type: object
          additionalProperties: { type: string }

    OnboardingStepApproval:
      type: object
      description: |
        One decision by a human authorizing (or refusing) an onboarding act Facturia then
        performs on their behalf (#452, #453). Append-only: a caller reading back takes the
        newest row for the act and branches on `decision`, because a rejection is a decision
        and not an absence.
      properties:
        object: { type: string, const: onboarding_step_approval }
        empresa_id: { type: string }
        step: { type: string }
        act:
          type: string
          description: What was authorized, as `pending_action.approval.act` named it at the time.
        decision: { type: string, enum: [approve, reject] }
        role: { type: string, enum: [titular_cuenta, rep_legal] }
        decided_via:
          type: string
          enum: [hosted, session]
          description: |
            `hosted` — a person holding the enlace de aprobacion pressed the button, the form
            ADR-0012 §1 calls canonical. `session` — the decision was stated through an
            authenticated surface.
        decided_at: { type: string, format: date-time }
        note: { type: [string, "null"] }
        queued:
          type: boolean
          description: Whether the fold tick was enqueued. Always `false` for a rejection — there is nothing to fold.
        onboarding_url: { type: string }
        next:
          $ref: "#/components/schemas/ApiCall"

    AutomationSettings:
      type: object
      description: Every automation defaults to OFF except `auto_request_folios`.
      properties:
        object: { type: string, const: automation_settings }
        empresa_id: { type: string }
        auto_emit_46: { type: boolean, default: false }
        auto_emit_46_max_amount:
          $ref: "#/components/schemas/Clp"
        auto_file_f29: { type: boolean, default: false }
        auto_file_f29_day: { type: integer, minimum: 1, maximum: 20, default: 18 }
        auto_file_f29_max_payable:
          $ref: "#/components/schemas/Clp"
          default: 0
          description: |
            Default 0 — out of the box only a zero or refund position files itself, and any
            month you actually owe IVA waits for a human. Raise deliberately, per empresa.
        auto_request_folios: { type: boolean, default: true }
        auto_request_folios_threshold: { type: integer, default: 50 }
        auto_accept_received: { type: boolean, default: false }
        auto_accept_received_after_days: { type: [integer, "null"], minimum: 1, maximum: 7 }
        require_confirmation:
          type: boolean
          default: false
          description: |
            When true, money-moving actions initiated from an agent surface — emitting a document,
            filing an F29, registering a reclamo through the MCP server's tools — require a
            draft → confirm two-step instead of executing on the first call. It exists primarily
            for the MCP surface (see `mcp-server-spec.md`); the REST API described here is
            unaffected, and the flag lives on this resource so one settings object governs the
            empresa on every surface.
        rcv_sync_frequency: { type: string, enum: [hourly, daily, weekly, manual], default: daily }
        honor_estado_contable_override:
          type: boolean
          default: false
          description: |
            Whether the F29 propuesta obeys the LOCAL `estado_contable_override` set through
            `POST /tax/purchases/{entry_id}/estado_contable` instead of the `estado_contable` the
            SII's own register supplied.

            Off by default, and the direction of that default is the point: only `REGISTRO`
            purchases carry a crédito fiscal, so a local `PENDIENTE` → `REGISTRO` flip claims IVA
            the SII does not agree exists — and the SII's value is the one an audit reads. Turned
            on, every affected propuesta carries an explicit observation naming the documents, and
            every write is recorded on the document's event log.
        updated_at: { type: string, format: date-time }

    Branding:
      type: object
      properties:
        object: { type: string, const: branding }
        logo_url: { type: string, format: uri }
        default_pdf_format:
          type: string
          enum: [carta]
          default: carta
          description: |
            `carta` is the only legal value — `80mm` was removed in wave 9 along with the `format`
            parameter, and no thermal layout exists. `PATCH` refuses any other value with
            `400 invalid_field` (ticket #18 wave 10). There is nothing further for it to select: it
            already names the only layout this API renders, so validating it is what "applying" it
            means.
        cedible_by_default:
          type: boolean
          default: false
          description: |
            Applies to facturas — tipo 33 and 34 (Ley 19.983; not "tipo 33 only", corrected in
            ticket #18 wave 10). Consumed by `GET /documents/{document_id}/pdf`'s default-copy
            resolution: a per-document `pdf.cedible` (§9.3) overrides it; an explicit `?copy=`
            overrides both. See that endpoint's `copy` parameter for the full precedence.
        footer_text: { type: string }

    # ---- folios

    Caf:
      type: object
      properties:
        object: { type: string, const: caf }
        id: { type: string }
        tipo_dte: { $ref: "#/components/schemas/TipoDte" }
        folio_from: { type: integer }
        folio_to: { type: integer }
        active: { type: boolean }
        source: { type: string, enum: [sii_request, upload] }
        created_at: { type: string, format: date-time }

    FolioRange:
      type: object
      properties:
        object: { type: string, const: folio_range }
        tipo_dte: { $ref: "#/components/schemas/TipoDte" }
        next_folio:
          type: [integer, "null"]
          description: >-
            The folio the NEXT emission takes — which, once the current range is spent, is the
            first folio of the range queued behind it, not the out-of-range number the counter
            is parked on. Null when no emission of this tipo could succeed right now, and null
            on the `sfg_managed` lane, where the SII assigns a folio per emission.
        available:
          type: [integer, "null"]
          description: >-
            Folios the next emissions can actually draw, ACROSS EVERY RANGE this empresa holds
            for the tipo — the current one plus each range queued behind it, since
            `allocate_dte_folio` rolls onto them rather than failing. Never derive a shortage
            from `next_folio` and a range end: an empresa drawing folio 25 of 6–25 while
            holding 26–45 has 21 available, not 1. Null (never zero) on `sfg_managed`.
        status: { type: string, enum: [ok, low, exhausted, sfg_managed] }
        stock:
          type: object
          description: >-
            Where `available` comes from: the ranges the allocator will consume, in the order it
            will consume them. Present on the mercado lane only.
          properties:
            chain:
              type: array
              items:
                type: object
                properties:
                  caf_id: { type: [string, "null"] }
                  from: { type: integer }
                  to: { type: integer }
                  remaining: { type: integer }
                  expired:
                    type: boolean
                    description: >-
                      Past its vigencia for a tipo Res. Ex. SII 58/2017 §1° binds. Contributes
                      nothing and ends the walk — the emission path refuses it.
            stops_at_expired_range:
              type: boolean
              description: >-
                The walk ended on a lapsed range rather than on "nothing more was granted". Not
                the same as `available == 0`: a chain can carry usable folios now and still stop
                at an expired range behind them.
        ranges:
          type: array
          items:
            type: object
            properties:
              caf_id: { type: string }
              from: { type: integer }
              to: { type: integer }
              expires_at: { type: [string, "null"], format: date-time }

    # ---- documents

    DocumentStatus:
      type: string
      enum: [queued, sent, accepted, reparo, rejected, failed, annulled]
      description: |
        queued → sent → accepted | reparo | rejected; failed is retriable; annulled follows a
        nota de crédito with code 1. See the estado mapping table in the prose spec for how each
        SII signal (DOK/DNK/FAU/FNA/FAN/EMP/RCT/EPR/RPR…) lands here.

    DocumentItem:
      type: object
      required: [description, quantity, unit_price]
      properties:
        description: { type: string, maxLength: 80 }
        detail: { type: string, maxLength: 1000 }
        quantity: { type: number }
        unit: { type: string, examples: ["UN", "KG", "HR"] }
        unit_price:
          type: number
          description: >-
            Up to 4 decimals. Net or gross depending on `prices_include_tax` — but on a factura
            (33/34/56/61/46) the document's own `<PrcItem>`/`<MontoItem>` is always NET
            (`DTE_v10.xsd`: `MontoItem` is net unless `MntBruto` is set, which this API never
            sends), so `prices_include_tax: true` there is normalized DOWN at the door: the
            echoed `items`, the PDF and `<PrcItem>` all show the NET price (`11990` →
            `10075.6303`), and the total may land a peso off the quoted gross figure because
            every downstream amount is computed from the rounded net price. On a boleta (39/41),
            whose `MontoItem` is gross by schema, the price stays gross regardless of
            `prices_include_tax`. On a guía de despacho (52) emitted through
            `emission_channel=sfg` it must be greater than 0 on every line — the SII portal
            validates its guía form with its factura validations and refuses a zero line
            whatever the traslado kind (`422 sfg_guia_zero_amount`). `emission_channel=mercado`
            accepts a zero-amount guía.
        discount_percent:
          type: number
          minimum: 0
          maximum: 100
          description: >-
            Per-line discount, emitted as the SII's `<DescuentoPct>`/`<DescuentoMonto>` — MERCADO
            CHANNEL ONLY. On `emission_channel=sfg` any value greater than 0 is refused with
            `422 line_discount_not_available_for_channel`: the SII portal's emission form has no
            per-line discount field we have ever captured (the captured form comments its discount
            inputs out), so accepting it would quote you a discounted total and file the
            undiscounted one. Fold the discount into `unit_price`, or emit through
            `emission_channel=mercado`. `global_discounts` is refused on that channel for the same
            reason. A `0` is accepted on both channels — it is exactly what the portal would file.
        amount:
          $ref: "#/components/schemas/Clp"
        item_code:
          type: object
          description: >-
            Emitted as the SII's `<CdgItem><TpoCodigo>/<VlrCodigo>`. Both `type` and `value` are
            required together; a half-filled pair is dropped rather than emitted as an empty
            element.
          properties:
            type: { type: string, examples: ["INT1", "EAN"] }
            value: { type: string }

    DocumentReference:
      type: object
      required: [tipo_dte, folio]
      properties:
        tipo_dte:
          type: integer
          description: |
            The referenced document's SII type code: a DTE type (33/34/39/41/46/52/56/61) or one of
            the SII's non-DTE reference codes in the 800 range (e.g. 801 orden de compra, 802 nota de pedido).
        folio:
          type: [string, integer]
          description: >-
            An integer is coerced to its decimal string, since `<FolioRef>` is `xs:string` in
            the SII's schema (a tipo 801 orden de compra folio like `OC-99213` is never a
            number). Sent either way, capped at 18 characters.
        date:
          type: string
          format: date
          description: Defaults to the document's own `issue_date` when omitted.
        code:
          type: [integer, "null"]
          enum: [1, 2, 3, null]
          description: 1 anula · 2 corrige texto · 3 corrige montos. Null for non-DTE references.
        reason: { type: string, maxLength: 90 }
        force:
          type: boolean
          default: false
          description: >-
            Reference a document that was emitted OUTSIDE Facturia. By default a reference to a
            DTE type with a numeric folio is checked against this empresa's own emitted documents
            and refused with `422 reference_not_found` (or `reference_not_accepted`) when the
            document is missing or the SII has not accepted it — a typo there costs the
            referencing document's own folio. Set `force: true` per reference for a factura issued
            by a previous provider, which is legitimate and unknowable to us. Never a default,
            and not a DTE field: it is consumed at the door.

    DocumentCreateRequest:
      type: object
      required: [tipo_dte, items]
      description: |
        `tipo_dte` and `items` are required UNLESS `preview_id` is sent, in which case the stored
        request is replayed and only `external_id` and `metadata` may accompany it.
      properties:
        preview_id:
          type: string
          examples: ["prev_01K2R9M3D8V5QW7N"]
          description: |
            Emit a previously computed preview (`POST /documents/previews`). The stored request is
            replayed through the ordinary create path, so nothing about the emission differs from a
            direct one. Only `external_id` and `metadata` may be supplied alongside it — nothing
            that could change an amount, a receptor or a date. Emitting the same preview twice
            returns the original document with `200` rather than a second `201`.
        tipo_dte: { $ref: "#/components/schemas/TipoDte" }
        external_id:
          type: string
          description: Your own identifier. Unique per empresa; a duplicate returns `409 external_id_exists`.
        issue_date:
          type: string
          format: date
          description: Defaults to today in America/Santiago.
        prices_include_tax:
          type: boolean
          description: Defaults to true for 39/41 and false for every other tipo.
        receptor:
          allOf: [{ $ref: "#/components/schemas/Party" }]
          description: >-
            Who the document is issued to. `giro` here is the SII's `GiroRecep`, capped at
            **40 characters** — tighter than the emisor's own `GiroEmis` (80), so the same name
            of trade can be legal on one side of a document and illegal on the other.
            `legal_name` is 100 and `address` is 70/20/20. Over-long values are refused with
            `400 field_too_long` before a folio is allocated, and never truncated: shortening a
            razón social or a name of trade would change what the document says about the
            parties. `address` must be an object; a bare string is `400 invalid_field`, because
            there is no way to split one into the three components the SII asks for and dropping
            it silently would emit a document with no address at all.
            AND THE ADDRESS IS NOT OPTIONAL on 33, 34, 46, 52, 56 and 61: the SII accepts the
            ENVELOPE and then REJECTS the document whose `<Receptor>` carries no
            `GiroRecep`/`DirRecep`/`CmnaRecep`, with the folio already spent. So `giro`,
            `address.street` and `address.comuna` are resolved from this empresa's own earlier
            documents to the same RUT and, failing that, refused with `400 receptor_incomplete`
            before a folio is allocated. Three exceptions: a tipo 52 needs no `giro` (its receptor
            is often the emisor itself, whose own giro frequently exceeds 40);
            `emission_channel=sfg` also needs `address.city`, which the SII portal's own form
            demands; and 39/41 and a foreign 46 (`foreign: true`, or `55555555-5`) need none of
            them.
        items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/DocumentItem" }
        global_discounts:
          type: array
          maxItems: 1
          description: >-
            The engine emits a single global discount line — the SII's `<DscRcgGlobal>` is one
            hard-coded `NroLinDR>1` — so a second entry is refused with `400 invalid_field`
            before a folio is allocated, rather than dropped. MERCADO CHANNEL ONLY: on
            `emission_channel=sfg` it is refused with
            `422 global_discount_not_available_for_channel`, because the SII portal's emission form
            has no discount field at all — its `EFXP_PCT_DESC`/`EFXP_MNT_DESC` inputs are commented
            out in the captured form, and `items[].discount_percent` is refused there for the same
            reason. Fold the discount into `items[].unit_price` on that channel.
          items:
            type: object
            properties:
              type: { type: string, enum: [percent, amount] }
              value: { type: number }
              applies_to: { type: string, enum: [afecto, exento] }
              reason: { type: string }
        references:
          type: array
          items: { $ref: "#/components/schemas/DocumentReference" }
        dispatch:
          type: object
          description: >-
            Required for tipo 52, and its `transfer_type` / `dispatch_type` are refused on any
            other tipo. Both emission channels emit a guía; they differ in which fields they can
            express. `destination` and `dispatch_type` are accepted on `emission_channel=mercado`
            (the engine writes them into the DTE's `IdDoc/TipoDespacho` and Transporte block) and
            refused on `emission_channel=sfg` with `422 dispatch_not_available_for_channel`,
            because the SII portal's guía form has nowhere to put them: it carries a single
            address — the receptor's, so put a differing one in `receptor.address` — and its only
            traslado select is the kind of movement, with no TipoDespacho input at all. The two
            channels also differ on amounts: `sfg` refuses a guía with any line priced at or below
            0, or a zero total, with `422 sfg_guia_zero_amount` — the portal validates the guía with
            its factura validations, whatever the traslado kind — while `mercado` accepts one.
          required: [transfer_type]
          properties:
            transfer_type:
              type: string
              description: >-
                The SII's IndTraslado — what the movement IS. Required; there is no default,
                because the SII's own form defaults to "operación constituye venta".
              enum:
                [
                  venta,
                  venta_por_efectuar,
                  consignacion,
                  entrega_gratuita,
                  traslado_interno,
                  otros,
                  devolucion,
                  traslado_exportacion,
                  venta_exportacion,
                ]
            dispatch_type:
              type: string
              enum: [receiver_account, issuer_to_customer_site, issuer_to_other_site]
              description: >-
                The SII's TipoDespacho — on whose account the despacho moves, and to whose
                premises. Optional: unlike `transfer_type` it changes nothing about what the
                document means, so a guía that omits it is complete. `receiver_account` = 1,
                despacho por cuenta del receptor del documento (the customer; the seller when the
                document is a factura de compra); `issuer_to_customer_site` = 2, por cuenta del
                emisor a instalaciones del cliente; `issuer_to_other_site` = 3, por cuenta del
                emisor a otras instalaciones (entrega en obra, bodega de un tercero). Accepted on
                `emission_channel=mercado`; refused on `sfg`, whose form has no such input. This
                field previously published `emisor_propio`/`emisor_arrendado`/`receptor`, which
                named whose vehicle moved the goods — a different question, refused on every
                channel and never written into a document; those three values are now
                `400 invalid_field`. Name the vehicle's owner with `carrier`.
            requester_rut: { $ref: "#/components/schemas/Rut" }
            carrier:
              type: object
              properties:
                rut: { $ref: "#/components/schemas/Rut" }
                name: { type: string }
                plate: { type: string, maxLength: 8 }
                driver_rut: { $ref: "#/components/schemas/Rut" }
                driver_name: { type: string, maxLength: 30 }
            destination:
              allOf: [{ $ref: "#/components/schemas/Address" }]
              description: >-
                Delivery address, when it differs from `receptor.address`. Accepted on
                `emission_channel=mercado`, where the engine writes it into the DTE's
                Transporte block as DirDest/CmnaDest/CiudadDest; refused on
                `emission_channel=sfg`. The SII caps these at 70/20/20 characters and
                longer values are refused with `400 field_too_long` rather than truncated.
        withholding:
          type: object
          description: Tipo 46 — you are the withholding agent.
          properties:
            iva:
              type: string
              enum: [total, partial]
              default: total
              description: >-
                Only `total` — the whole IVA retained, the SII's ImpAdicDTE 15 «IVA Retenido
                Total» — is emittable today. `partial` is refused with
                `422 withholding_not_supported`: the engine has no way to state a partial rate
                yet.
            rate:
              type: number
              description: >-
                Only `19` is accepted. A total IVA retención is retained at 19% and the document
                has nowhere to state a different one; any other value is refused with
                `422 withholding_not_supported`.
        payment:
          type: object
          properties:
            terms: { type: string, enum: [contado, credito, sin_costo] }
            due_date: { type: string, format: date }
            method: { type: string }
        totals:
          allOf:
            - $ref: "#/components/schemas/Totals"
            - description: |
                Optional assertion. When present it must match our computation exactly,
                or the request fails with `422 totals_mismatch` and nothing is emitted.
        pdf:
          type: object
          properties:
            cedible:
              type: boolean
              description: |
                This document's own default copy for `GET /documents/{document_id}/pdf` when a read
                carries no `?copy=` of its own — `true` defaults to the cedible copy, `false`
                explicitly defaults to the original (overriding the empresa's
                `branding.cedible_by_default`, §7.6). An explicit `?copy=` on the read always wins
                regardless of this field. `true` on a tipo outside 33/34 is `422
                cedible_not_available` — refused before a folio is spent, not silently ignored.
            public:
              type: boolean
              description: |
                Also mint a signed, unguessable, expiring URL scoped to this one artifact, openable
                without a key. It comes back as `public_links.pdf_public_url` on THIS response and
                nowhere else — the secret half is stored hashed, so no later read can reproduce it.
            link_expires_in_days:
              type: [integer, "null"]
              minimum: 1
              maximum: 365
              default: 90
              description: Null means the link does not expire.
            link_expires_in_seconds:
              type: [integer, "null"]
              description: The same lifetime in seconds, clamped to 30s–365d. Wins when both are sent.
        xml:
          type: object
          description: |
            The XML artifact shares the PDF's public-link mechanism on identical terms — it is the
            artifact the receptor's accounting system actually ingests, so gating it behind a key
            would force integrators to proxy a file we have already generated.
          properties:
            public:
              type: boolean
              description: |
                Also mint a public link serving the signed bytes exactly as sent to the SII
                (`application/xml; charset=ISO-8859-1`), byte-identical to the authenticated route.
                It comes back as `public_links.xml_public_url` on THIS response and nowhere else.
            link_expires_in_days:
              type: [integer, "null"]
              minimum: 1
              maximum: 365
              default: 90
            link_expires_in_seconds:
              type: [integer, "null"]
              description: The same lifetime in seconds, clamped to 30s–365d. Wins when both are sent.
        notes: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
          description: Up to 20 keys, ≤500 chars each. Stored and returned untouched; never sent to the SII.
        test:
          type: object
          description: Test mode only. Sending this with a live key fails with `400 test_field_in_live_mode`.
          properties:
            outcome: { type: string, enum: [accepted, reparo, rejected, failed, stuck], default: accepted }
            reason_code: { type: string }
            settle_after_ms: { type: integer, minimum: 0, maximum: 60000 }

    DocumentPreview:
      type: object
      description: |
        A priced, fully validated document that was not emitted. Returned by
        `POST /documents/previews` and readable for an hour.
      properties:
        object: { type: string, const: document_preview }
        id: { type: string, examples: ["prev_01K2R9M3D8V5QW7N"] }
        preview_id:
          type: string
          description: |
            The same value as `id`, published under the name `POST /documents` takes it by. Both
            exist so neither a REST reader nor an agent has to translate.
        mode: { $ref: "#/components/schemas/Mode" }
        empresa:
          type: object
          properties:
            id: { type: string }
            rut: { $ref: "#/components/schemas/Rut" }
        tipo_dte: { $ref: "#/components/schemas/TipoDte" }
        issue_date: { type: string, format: date }
        external_id: { type: [string, "null"] }
        resolved_receptor:
          type: object
          description: How the receptor was identified, and to what.
          properties:
            rut: { type: [string, "null"] }
            legal_name: { type: [string, "null"] }
            resolution_method:
              type: string
              enum: [rut_exact, anonymous, needs_clarification]
              description: |
                `needs_clarification` means a name was given rather than a RUT, so the document
                would go to the anonymous consumer. Ask for the RUT; we will not guess one.
        items: { type: array, items: { $ref: "#/components/schemas/DocumentItem" } }
        totals: { $ref: "#/components/schemas/Totals" }
        references: { type: array, items: { $ref: "#/components/schemas/DocumentReference" } }
        prices_include_tax: { type: boolean }
        metadata: { type: object, additionalProperties: { type: string } }
        folio_preview:
          type: [integer, "null"]
          description: |
            The folio that WOULD be used, read from the counter and not reserved.

            IN THE SANDBOX IT IS ALWAYS A NUMBER (§4.2, #520): sandbox folios are virtual —
            `1..1000000`, no CAF, no timbraje — and the counter row is created at the first
            emission, so a brand-new empresa forecasts `1` rather than `null`. It used to answer
            `null` from the absent row while `GET /v1/folios` answered «next folio 1, 1000000
            left, ok» from the same absence, and the preview then warned that emitting would fail;
            it did not.

            `null` means «not forecastable», and there are exactly two ways to reach it: a LIVE
            mercado empresa with no folios left (or past the end of its CAF), and any SFG empresa,
            where the SII assigns the folio at emission and there is nothing of ours to forecast.
        folio:
          type: [integer, "null"]
          description: |
            ALWAYS `null`. No folio was taken, and saying so explicitly beats omitting it — the
            forecast lives in `folio_forecast` beside it.

            Declared `[integer, "null"]` and not the bare `"null"` it used to be: a pure-null
            property aborts `openapi-generator`'s Python target for the WHOLE document
            (`RuntimeException: Codegen Property not yet supported in getPydanticType`, #476), so
            one field nobody reads cost every operation in the contract. The `integer` half names
            what the value would be if it were ever not null; the sentence above is the promise.
        emitted:
          type: boolean
          description: True once this preview has produced a document.
        consumed_at:
          type: [string, "null"]
          format: date-time
          description: Present on a read; when the preview was emitted.
        warnings:
          type: array
          description: |
            Non-blocking findings. Empty on a preview with nothing to say, which is the ordinary
            case — and, in the SANDBOX, the only case a stock warning can produce (#520).

            **Branch on `code`, never on `message`** — the same rule the error envelope states.
            The message is English prose written for a developer reading a log; a panel showing
            this to a person maps the `code` to its own Spanish, which it cannot do by matching a
            sentence that may be reworded.
          items: { $ref: "#/components/schemas/PreviewWarning" }
        created_at: { type: string, format: date-time }
        expires_at:
          type: string
          format: date-time
          description: One hour after creation. An hour is a conversation, not a record.

    PreviewWarning:
      type: object
      title: PreviewWarning
      description: |
        One non-blocking finding on a draft. Declared as a schema of its own (#520) because it was
        an inline `{code, message, param}` with no vocabulary published anywhere — so a panel that
        wanted to show a person Spanish had to match the English sentence, and an agent had nothing
        stable to branch on.

        A warning NEVER blocks the emission. `POST /documents` with this `preview_id` proceeds; if
        a finding were a reason to refuse, it would be a `422` with an error code instead.
      required: [code, message]
      properties:
        code:
          type: string
          enum: [receptor_unresolved, no_folios_available]
          description: |
            The stable identifier. §5.8's twelve-month promise applies here as it does to an error
            `code`, and the enum grows additively — an unrecognised value is never a fault, so
            treat one you do not know as «something worth relaying, verbatim».

              * `receptor_unresolved` — a NAME was given where a RUT belongs, so the document would
                be addressed to the anonymous consumer rather than to the person meant. `param` is
                `receptor.rut`. Ask for the RUT; we will not guess one.
              * `no_folios_available` — LIVE `emission_channel=mercado` only. The empresa has no
                folio left for this tipo (or is past the end of its CAF), and emitting will fail
                until folios are requested. It cannot arrive from the SANDBOX, where folios are
                virtual and there is nothing to request, nor from an SFG empresa, where the SII
                assigns the folio at emission.
        message:
          type: string
          description: |
            ENGLISH. Developer prose, like `error.message` — read `info.description`'s language
            rule. Show it if you must; branch on `code`.
        param:
          type: string
          description: The request field the finding is about, when it is about one.
          examples: ["receptor.rut"]

    PublicLinks:
      type: object
      description: |
        Freshly minted public links. Returned by `POST /documents/{document_id}/public_links` and
        `POST /tax/f29/proposals/{period}/public_links`.

        THE URLS EXIST ONLY HERE. The secret half of each link is stored hashed, exactly as an API
        key is, so a working URL cannot be reconstructed afterwards — the property that stops a
        database dump from being a document dump. Capture what you mint.
      properties:
        object: { type: string, const: public_links }
        document_id:
          type: string
          description: Present when the subject is a document.
        period:
          $ref: "#/components/schemas/Period"
        proposal_id:
          type: string
          description: Present when the subject is an F29 propuesta.
        empresa:
          type: object
          properties:
            id: { type: string }
            rut: { $ref: "#/components/schemas/Rut" }
        pdf_public_url: { type: [string, "null"], format: uri }
        xml_public_url: { type: [string, "null"], format: uri }
        cedible_public_url:
          type: [string, "null"]
          format: uri
          description: |
            Present only when `cedible` was in the minting request's `artifacts` (ticket #18 wave
            10). `null` otherwise — including on a document whose tipo cannot carry one.
        upload_file_public_url: { type: [string, "null"], format: uri }
        expires_in_seconds: { type: [integer, "null"] }
        expires_in_days:
          type: [number, "null"]
          description: |
            The same lifetime in days, deliberately fractional: a ten-minute link is `0.00694`, and
            reporting it as `0` would be a different and wrong statement.

    ErrorCode:
      type: object
      description: One entry of the published error dictionary (`GET /error_codes`).
      properties:
        code: { type: string, examples: ["ted_signature_invalid"] }
        type:
          type: string
          description: |
            The `error.type` family it arrives under, or `rejection` for a code that appears inside
            a document's `rejection_reasons[]` rather than as a response.
          examples: ["rejection"]
        http_status:
          type: [integer, "null"]
          description: Null for `rejection` rows, which are not responses.
        sii_code: { type: [string, "null"], examples: ["TED-2-510"] }
        message: { type: string }
        hint: { type: string }
        retriable:
          type: boolean
          description: Could the IDENTICAL request succeed later?
        doc_url:
          type: string
          format: uri
          description: The same URL `error.doc_url` carries.

    Document:
      type: object
      description: |
        An emitted DTE. It carries NO public-link field: `pdf_public_url` and `xml_public_url` were
        withdrawn (ticket #18) because the link secret is stored hashed and unrecoverable, so a read
        could only ever answer `null` or a URL that 404s. A working, `?k=`-bearing URL exists solely
        inside the `public_links` object of the response that MINTED it — `POST
        /documents/{document_id}/public_links`, or an emission that asked for one (see
        `EmittedDocument`). Reads, lists, expansions and webhook payloads never carry one.
      properties:
        object: { type: string, const: document }
        id: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        empresa:
          type: object
          properties:
            id: { type: string }
            rut: { $ref: "#/components/schemas/Rut" }
        external_id: { type: [string, "null"] }
        tipo_dte: { $ref: "#/components/schemas/TipoDte" }
        folio: { type: [integer, "null"] }
        issue_date: { type: string, format: date }
        status: { $ref: "#/components/schemas/DocumentStatus" }
        receptor: { $ref: "#/components/schemas/Party" }
        items:
          type: array
          items: { $ref: "#/components/schemas/DocumentItem" }
        totals: { $ref: "#/components/schemas/Totals" }
        references:
          type: array
          items: { $ref: "#/components/schemas/DocumentReference" }
        pdf_url: { type: string, format: uri }
        xml_url: { type: string, format: uri }
        xml:
          type: string
          description: Present only with `expand=xml`.
        pdf_base64:
          type: string
          description: Present only with `expand=pdf_base64`.
        sii: { $ref: "#/components/schemas/SiiState" }
        cesion: { $ref: "#/components/schemas/CesionState" }
        rejection_reasons:
          type: array
          items: { $ref: "#/components/schemas/RejectionReason" }
        annulled_by:
          type: [string, "null"]
          description: Document id of the nota de crédito that annulled this one.
        metadata:
          type: object
          additionalProperties: { type: string }
        created_at: { type: string, format: date-time }
        accepted_at: { type: [string, "null"], format: date-time }

    EmittedDocument:
      description: |
        The document object as returned by `POST /documents` — the one response that can MINT a
        public link, and therefore the one response that can carry a working URL. It is a
        `Document` plus an optional `public_links` object; nothing else about the shape differs.
      allOf:
        - $ref: "#/components/schemas/Document"
        - type: object
          properties:
            public_links:
              type: object
              description: |
                Present ONLY when the emission asked for `pdf: {public: true}` and/or
                `xml: {public: true}` — ABSENT, never null, otherwise. These URLs include their
                `?k=` secret and are shown exactly once: the secret is stored hashed, so no later
                request can reproduce them. Capture them here or mint fresh ones with
                `POST /documents/{document_id}/public_links`.

                No expiry is echoed: `pdf` and `xml` carry independent `link_expires_in_*` values
                at emission, so a single pair of expiry fields would be wrong whenever they differ.
              properties:
                object: { type: string, const: public_links }
                pdf_public_url:
                  type: [string, "null"]
                  format: uri
                  description: Null when only the XML link was requested.
                xml_public_url:
                  type: [string, "null"]
                  format: uri
                  description: Null when only the PDF link was requested.

    SiiState:
      type: object
      properties:
        environment: { type: string, enum: [sandbox, certificacion, produccion] }
        track_id: { type: [string, "null"] }
        estado:
          type: [string, "null"]
          description: Raw SII estado — DOK, DNK, FAU, FNA, FAN, EMP, EPR, RCT, RPR, RLV…
        glosa: { type: [string, "null"] }
        err_code: { type: [string, "null"] }
        num_atencion:
          type: [string, "null"]
          description: SII case number. Quote it to the SII mesa de ayuda.
        modified_by:
          type: array
          description: Notas de crédito/débito the SII reports against this document (TMD/TMC/MMD/MMC).
          items: { type: string }
        received_at: { type: [string, "null"], format: date-time }
        last_checked_at: { type: [string, "null"], format: date-time }

    CesionState:
      type: object
      description: |
        Ley 19.983 cesibilidad, as the SII's registro reports it (ticket #47). A CACHE of one
        remote answer, never a local computation: whether a factura can be assigned to a
        factoring company depends on the receptor's acuse — or eight días corridos of their
        silence — and on whether the emisor declared the sale al contado, and only the SII knows.

        All three fields are null until `POST /documents/{document_id}/cesion` has been answered
        once. NOT the same thing as the cedible PDF copy, which exists by tipo and is served by
        `GET /documents/{document_id}/pdf?copy=cedible`.
      properties:
        cedible:
          type: [boolean, "null"]
          description: |
            True for SII response codes 23 (express acuse) and 25 (the eight days lapsed), false
            for 24 and for 21 (an annulment credit note references the factura). Null when nobody
            has asked yet, or when the SII answered a code we do not read as a verdict —
            `sii_status_code` carries it verbatim rather than us guessing.
        sii_status_code:
          type: [integer, "null"]
          description: The raw `codResp` from `consultarDocDteCedible`.
          examples: [25]
        checked_at:
          type: [string, "null"]
          format: date-time
          description: |
            When the SII last ANSWERED — not when we last tried. A transient wire failure leaves
            this untouched so the queue's own retry re-asks.
        status:
          type: [string, "null"]
          enum: [queued, submitted, aec_generated, confirmed, refused, failed, simulated, null]
          description: |
            The newest `POST /documents/{document_id}/cede` attempt's outcome (ticket #60) — a
            DIFFERENT question from `cedible` above it ("could this document be cedida at all?"
            vs. "did an actual cede attempt go through?"). Null when no attempt has ever been
            made. Present only from `GET /documents/{document_id}` — omitted from list reads and
            from other actions' responses, which do not pay for the extra lookup.

            `queued` (accepted, not yet picked up) → one of:

            * `submitted` — the AEC was assembled, signed and UPLOADED to the SII's Registro
              Electrónico de Transferencia de Crédito, which answered «Envío recibido OK» and
              issued a TrackID. **THIS IS NOT REGISTRATION** (ticket #69). The registry validates
              afterwards and notifies a rejection by e-mail to the cedente, hours later. Do not
              settle money on this value.
            * `confirmed` — the registry's own `wsRPETCConsulta.getEstEnvio` answered `EOK`
              («Envío Aceptado») for that TrackID. That is the SII saying the cesión is anotada,
              and it is the only thing that writes this status. An attempt whose envelope never
              settles within the poll budget KEEPS `submitted` — the AEC really was received —
              while its job settles `failed` with `cesion_registration_unconfirmed`.
            * `refused` — the SII said no: it refused the upload (the authenticated user has no
              «Envío de Documentos» permission for the cedente empresa, or the empresa is not a
              DTE emisor), or the registry rejected the envelope at one of its validation stages.
              Terminal, and never a transport problem.
            * `failed` — a fenced attempt, a malformed upload, an SII-side internal error, a
              transport failure, or an answer nobody recognises and archived for review rather
              than acted on.
            * `aec_generated` — LEGACY. The retired Portal MIPYME walk reached its «AEC ha sido
              generado exitosamente» page, which produced a FILE and registered nothing. Nothing
              writes this any more; it remains only so historical attempts keep their meaning.
            * `simulated` — test mode; nothing reached the SII.

    CesionJob:
      type: object
      description: The `job` object `POST /documents/{document_id}/cede` returns (ticket #60).
      properties:
        object: { type: string, const: job }
        id: { type: [string, "null"] }
        type: { type: string, const: cesion_submit }
        status: { type: string, enum: [queued, running, succeeded, failed] }
        empresa_id: { type: string }
        document_id: { type: [string, "null"] }
        cesionario_rut: { $ref: "#/components/schemas/Rut" }
        created_at: { type: string, format: date-time }

    RejectionReason:
      type: object
      properties:
        code: { type: string, examples: ["ted_signature_invalid"] }
        sii_code: { type: [string, "null"], examples: ["TED-2-510"] }
        message: { type: string }
        hint: { type: string }
        retriable: { type: boolean }

    DocumentEvent:
      type: object
      properties:
        type: { type: string, examples: ["document.accepted"] }
        at: { type: string, format: date-time }
        detail:
          type: object
          additionalProperties: true

    # ---- received documents

    CommercialResponseState:
      type: string
      enum: [pending, accepted, rejected, receipt_acknowledged, expired_accepted, not_applicable]

    CommercialResponse:
      type: object
      properties:
        object: { type: string, const: commercial_response }
        received_document_id: { type: string }
        action: { type: string, enum: [accept, reject, acknowledge_receipt] }
        reason: { type: [string, "null"], enum: [content, partial_missing, total_missing, null] }
        sii_code:
          type: string
          enum: [ACD, RCD, ERM, RFP, RFT]
        registered_at: { type: string, format: date-time }
        sii:
          type: object
          properties:
            estado: { type: string }
            glosa: { type: string }
        state: { $ref: "#/components/schemas/CommercialResponseState" }

    ReceivedDocument:
      type: object
      properties:
        object: { type: string, const: received_document }
        id: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        tipo_dte: { $ref: "#/components/schemas/TipoDte" }
        folio: { type: integer }
        emisor: { $ref: "#/components/schemas/Party" }
        issue_date: { type: string, format: date }
        received_at: { type: string, format: date-time }
        source: { type: string, enum: [intercambio, rcv, manual] }
        totals: { $ref: "#/components/schemas/Totals" }
        items:
          type: array
          items: { $ref: "#/components/schemas/DocumentItem" }
        references:
          type: array
          items: { $ref: "#/components/schemas/DocumentReference" }
        xml_url: { type: string, format: uri }
        pdf_url: { type: string, format: uri }
        raw_xml:
          type: string
          description: Present only with `expand=raw_xml`. The supplier's original bytes, signature intact.
        sii:
          type: object
          properties:
            estado: { type: [string, "null"] }
            cedible: { type: boolean }
        rcv:
          type: object
          properties:
            entry_id: { type: [string, "null"] }
            estado_contable: { $ref: "#/components/schemas/EstadoContable" }
            period: { $ref: "#/components/schemas/Period" }
        commercial_response:
          type: object
          properties:
            state: { $ref: "#/components/schemas/CommercialResponseState" }
            state_reason:
              type: [string, "null"]
              enum: [anchor_never_registered, null]
              description: |
                Why the state is what it is, when the state name understates it. `null` for
                every ordinary state. `anchor_never_registered` on an `expired_accepted`
                whose SII receipt timestamp never arrived, so the window closed without a
                deadline ever being asserted — `deadline_at` and `days_remaining` are `null`
                on such a document and this is what says why.
            deadline_at:
              type: [string, "null"]
              format: date-time
              description: |
                End of the statutory 8-day window: the last second of the Santiago civil day
                `date(sii_receipt_at, America/Santiago) + 8`, with Chile's offset (`-04:00`
                or `-03:00`). Types 33/34/43 only.
            days_remaining:
              type: [integer, "null"]
              description: |
                Whole Santiago civil days left in the statutory 8-day window. `0` means
                "today is the last day" — the window is STILL OPEN, not expired; cross-check
                `state`/`state_reason` (or `windowState` server-side) rather than treating `0`
                as a terminal value. Once the deadline has passed this is NEGATIVE — the
                count of days overdue — rather than saturating at `0`, so a document whose
                window closed yesterday and one that closed three weeks ago are
                distinguishable from this field alone. `null` only when there is no deadline
                to count toward (no SII receipt timestamp yet, or not a 33/34/43).
            available_actions:
              type: array
              items: { type: string, enum: [accept, reject_content, reject_partial, reject_total, acknowledge_receipt] }
            default_if_silent:
              type: string
              const: accepted
              description: Aceptación tácita — doing nothing accepts on day 8. This is why nothing is automatic.
            history:
              type: array
              items: { $ref: "#/components/schemas/CommercialResponse" }
        created_at: { type: string, format: date-time }

    # ---- tax

    EstadoContable:
      type: string
      enum: [REGISTRO, PENDIENTE, NO_INCLUIR, RECLAMADO]

    Characterization:
      type: object
      properties:
        tipo_compra:
          type: string
          enum: [del_giro, supermercado, activo_fijo, iva_uso_comun, no_recuperable]
        iva_uso_comun: { type: boolean }
        iva_no_recuperable_code:
          type: [integer, "null"]
          description: SII reason code, required when `tipo_compra` is `no_recuperable`.

    CharacterizationAction:
      type: object
      description: |
        A queued caracterización write. `status` settles to `applied` only when the write really
        reached the SII; a run made with live writes disabled settles to `dry_run` and the SII's
        register is unchanged.
      properties:
        object: { type: string, const: characterization_action }
        id: { type: string }
        rcv_entry_id: { type: string }
        status:
          type: string
          enum: [draft, queued, submitting, applied, dry_run, rejected, failed, refused]
        tipo_compra:
          type: string
          enum: [del_giro, supermercado, activo_fijo, iva_uso_comun, no_recuperable]
        iva_no_recuperable_code: { type: [integer, "null"] }
        previous_tipo_compra: { type: [string, "null"] }
        reverts:
          type: string
          description: Present only on a revert — the id of the action being undone.
        dry_run:
          type: boolean
          description: TRUE until an executor that really wrote to the SII clears it.
        live_writes_enabled:
          type: boolean
          description: |
            Whether a caracterización queued by this build can reach the SII at all. FALSE means the
            action is a rehearsal by construction: it will settle as `status: "dry_run"` and the SII's
            tipo de compra will not change. Distinct from `dry_run`, which is TRUE on any row that has
            not been submitted YET.
        note: { type: string }
        created_at: { type: string, format: date-time }

    RcvEntry:
      type: object
      properties:
        object: { type: string, const: rcv_entry }
        id: { type: string }
        direction: { type: string, enum: [purchase, sale] }
        period: { $ref: "#/components/schemas/Period" }
        tipo_dte: { type: integer }
        folio: { type: integer }
        counterparty: { $ref: "#/components/schemas/Party" }
        issue_date: { type: string, format: date }
        receipt_date: { type: [string, "null"], format: date }
        amounts:
          allOf:
            - $ref: "#/components/schemas/Totals"
            - type: object
              properties:
                iva_no_recuperable: { $ref: "#/components/schemas/Clp" }
        estado_contable:
          $ref: "#/components/schemas/EstadoContable"
          description: |
            The SII's own value, from its register. Never written by
            `POST /tax/purchases/{entry_id}/estado_contable` — that records the caller's intent
            in `estado_contable_override` instead, so a resync can always correct us.
        estado_contable_override:
          type: [object, "null"]
          description: The local bookkeeping estado a caller set, or null. Read by the F29 only when the empresa's `honor_estado_contable_override` is on.
          properties:
            value: { type: string, enum: [REGISTRO, PENDIENTE, NO_INCLUIR] }
            set_by: { type: [string, "null"], description: The API key that set it. }
            set_at: { type: [string, "null"], format: date-time }
            honored: { type: boolean, description: Whether the F29 will act on it. }
        estado_contable_effective:
          $ref: "#/components/schemas/EstadoContable"
          description: What the F29 propuesta will actually use — the override when honored, the SII's value otherwise.
        missing_at_sii:
          type: boolean
          description: |
            True when the SII's REGISTRO detalle for this entry's registry period stopped returning
            it — reclamado, anulado, or moved out of the registry. Excluded from every F29 aggregate
            while true. The entry is kept rather than deleted: it is evidence that it was there.
        missing_at_sii_at: { type: [string, "null"], format: date-time }
        characterization: { $ref: "#/components/schemas/Characterization" }
        document_id:
          type: [string, "null"]
          description: Set when this entry corresponds to a document Facturia emitted.
        received_document_id: { type: [string, "null"] }
        synced_at: { type: string, format: date-time }

    RcvEntryList:
      allOf:
        - $ref: "#/components/schemas/List"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/RcvEntry" }
            sync:
              type: object
              description: |
                Freshness of the underlying SII sync. `degraded` means we know the data is stale.

                **Scoped to `period` when the request names one.** `last_synced_at` is then the
                completion of the newest sync THAT READ THAT MONTH, and `null` means the month has
                never been fetched — the empty `data` above is "unknown", not "no documents". Without
                a `period` filter the block describes the empresa's newest sync of any month.
              properties:
                last_synced_at: { type: [string, "null"], format: date-time }
                status: { type: string, enum: [fresh, syncing, stale, degraded] }
                period_closed: { type: boolean }

    TaxSummary:
      type: object
      properties:
        object: { type: string, const: tax_summary }
        period: { $ref: "#/components/schemas/Period" }
        purchases:
          type: array
          items: { $ref: "#/components/schemas/TaxSummaryLine" }
        sales:
          type: array
          items: { $ref: "#/components/schemas/TaxSummaryLine" }
        totals:
          type: object
          properties:
            iva_debito: { $ref: "#/components/schemas/Clp" }
            iva_credito: { $ref: "#/components/schemas/Clp" }

    TaxSummaryLine:
      type: object
      properties:
        tipo_dte: { type: integer }
        count: { type: integer }
        net: { $ref: "#/components/schemas/Clp" }
        exempt: { $ref: "#/components/schemas/Clp" }
        iva: { $ref: "#/components/schemas/Clp" }
        total: { $ref: "#/components/schemas/Clp" }

    F29Proposal:
      type: object
      properties:
        object: { type: string, const: f29_proposal }
        id: { type: string }
        period: { $ref: "#/components/schemas/Period" }
        status: { type: string, enum: [computing, ready, stale, blocked] }
        computed_at: { type: string, format: date-time }
        based_on:
          type: object
          properties:
            rcv_synced_at: { type: string, format: date-time }
            purchases: { type: integer }
            sales: { type: integer }
        summary:
          type: object
          properties:
            iva_debito: { $ref: "#/components/schemas/Clp" }
            iva_credito: { $ref: "#/components/schemas/Clp" }
            iva_retenido: { $ref: "#/components/schemas/Clp" }
            remanente_anterior: { $ref: "#/components/schemas/Clp" }
            ppm: { $ref: "#/components/schemas/Clp" }
            total_payable: { $ref: "#/components/schemas/Clp" }
            due_date: { type: string, format: date }
        codes:
          type: array
          description: Exactly what the upload file will carry, code for code.
          items:
            type: object
            properties:
              code: { type: integer, examples: [538, 537, 39, 519, 520, 77, 62, 91] }
              label: { type: string }
              value: { type: integer }
        observations:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              severity: { type: string, enum: [info, warning, error] }
              message: { type: string }
              entry_ids:
                type: array
                items: { type: string }
        upload_file_url: { type: string, format: uri }
        filing_id: { type: [string, "null"] }

    F29Filing:
      type: object
      properties:
        object: { type: string, const: f29_filing }
        id: { type: string }
        period: { $ref: "#/components/schemas/Period" }
        status:
          type: string
          enum: [queued, preparing, uploading, submitted, dry_run, accepted, rejected, failed, refused]
          description: |
            Modelled as a job because that is what it is: the SII has no F29 webservice, so
            filing is a certificate-authenticated session submitting a fixed-format .txt.

            `dry_run` is the terminal state of a rehearsal: the upload file was built and validated
            and NO request was made to the SII. It does not hold the period — a later filing for the
            same month is accepted rather than answered `409 f29_filing_already_in_flight`.
            `submitted` means the SII has the file and has not answered yet, and that one does hold
            the period.
        declared:
          type: object
          properties:
            total_payable: { $ref: "#/components/schemas/Clp" }
        attempts: { type: integer }
        folio: { type: [string, "null"] }
        receipt_url: { type: [string, "null"], format: uri }
        errors:
          type: array
          items: { $ref: "#/components/schemas/RejectionReason" }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    ExpenseCreateRequest:
      type: object
      required: [provider, amount_original, currency, date]
      properties:
        provider:
          type: object
          required: [name]
          properties:
            name: { type: string }
            country: { type: string, description: ISO 3166-1 alpha-2. }
            rut: { $ref: "#/components/schemas/Rut" }
        description: { type: string }
        amount_original: { type: number }
        currency: { type: string, examples: ["USD", "EUR"] }
        date: { type: string, format: date }
        fx_rate:
          type: number
          description: |
            REQUIRED whenever `currency` is not `CLP`; omitting it returns `400 fx_rate_required`.
            Facturia never invents a rate — it sets the peso base of a tax document.
        fx_source:
          type: string
          enum: [card_rate, sii_observado, custom]
          description: |
            `card_rate` (the settlement rate the issuer actually applied) is the documented
            recommendation. CAVEAT: which rate is definitively correct for tipo 46 on foreign
            digital services is under review with an accountant and is not settled by a citable
            ruling; the value you send is stored verbatim and shown on the document.
        external_id: { type: string }

    Expense:
      allOf:
        - $ref: "#/components/schemas/ExpenseCreateRequest"
        - type: object
          properties:
            object: { type: string, const: expense }
            id: { type: string }
            source:
              type: string
              enum: [api, fintoc, statement]
              default: api
              description: |
                How this charge arrived. A connector-created expense is otherwise indistinguishable
                from one you posted yourself.
            connection_id: { type: [string, "null"] }
            statement_id: { type: [string, "null"] }
            amount_clp: { $ref: "#/components/schemas/Clp" }
            period: { $ref: "#/components/schemas/Period" }
            suggestion_id: { type: [string, "null"] }
            document_id: { type: [string, "null"] }
            created_at: { type: string, format: date-time }

    Connection:
      type: object
      properties:
        object: { type: string, const: connection }
        id: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        provider: { type: string, enum: [fintoc] }
        status:
          type: string
          enum: [pending_link, active, requires_relink, disconnected, error]
          description: |
            `requires_relink` is the near-universal failure mode of bank feeds; it raises
            `connection.requires_relink` so a wizard can prompt the user instead of the feed
            silently going quiet.
        link_url: { type: [string, "null"], format: uri }
        accounts:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              type: { type: string, enum: [checking, credit_card] }
              name: { type: string }
              currency: { type: string }
              last4: { type: string }
        sync_frequency: { type: string, enum: [daily, weekly, manual] }
        last_synced_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Statement:
      type: object
      properties:
        object: { type: string, const: statement }
        id: { type: string }
        filename: { type: string }
        format: { type: string }
        status: { type: string, enum: [queued, processing, imported, failed] }
        rows_read: { type: integer }
        expenses_created: { type: integer }
        duplicates_skipped: { type: integer }
        errors:
          type: array
          items:
            type: object
            properties:
              row: { type: integer }
              message: { type: string }
        uploaded_at: { type: string, format: date-time }

    SuggestionType:
      type: string
      enum: [emit_46, missing_characterization, folios_low, unclaimed_credit]

    Suggestion:
      type: object
      properties:
        object: { type: string, const: suggestion }
        id: { type: string }
        type: { $ref: "#/components/schemas/SuggestionType" }
        status: { type: string, enum: [open, confirmed, auto_confirmed, dismissed, expired] }
        period: { $ref: "#/components/schemas/Period" }
        confidence: { type: number, minimum: 0, maximum: 1 }
        reason: { type: string }
        estimated_benefit:
          type: object
          properties:
            iva_credit: { $ref: "#/components/schemas/Clp" }
        source:
          type: object
          properties:
            object: { type: string }
            id: { type: string }
            origin:
              type: string
              enum: [api, fintoc, statement]
              description: How the underlying charge reached Facturia.
        proposed_document: { $ref: "#/components/schemas/DocumentCreateRequest" }
        f29_effect:
          type: array
          items:
            type: object
            properties:
              code: { type: integer }
              value: { type: integer }
        document_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    # ---- webhooks

    WebhookEndpoint:
      type: object
      properties:
        object: { type: string, const: webhook_endpoint }
        id: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        url: { type: string, format: uri }
        description: { type: string }
        enabled_events:
          type: array
          items: { type: string }
        empresas:
          type: array
          items: { $ref: "#/components/schemas/Rut" }
        status: { type: string, enum: [enabled, disabled] }
        created_at: { type: string, format: date-time }

    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          properties:
            secret:
              type: string
              description: Returned once, on creation or rotation. Store it; it is never shown again.
              examples: ["whsec_9Kd2mQ7xR4vN8pL1tY6bZ3wA5cE0uJhF"]

    WebhookDelivery:
      type: object
      properties:
        object: { type: string, const: webhook_delivery }
        id: { type: string }
        webhook_endpoint_id: { type: string }
        event_id: { type: string }
        status: { type: string, enum: [pending, succeeded, failed] }
        attempt: { type: integer }
        response_status: { type: [integer, "null"] }
        error: { type: [string, "null"] }
        next_attempt_at:
          type: [string, "null"]
          format: date-time
          description: Retry schedule — 30s, 2m, 10m, 1h, 3h, 6h, 12h, 24h, with jitter.
        delivered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Event:
      type: object
      properties:
        object: { type: string, const: event }
        id: { type: string, examples: ["evt_01K2RG1P4M7X"] }
        type: { type: string, examples: ["document.accepted"] }
        api_version: { type: string, const: v1 }
        mode: { $ref: "#/components/schemas/Mode" }
        created_at: { type: string, format: date-time }
        empresa:
          type: object
          properties:
            id: { type: string }
            rut: { $ref: "#/components/schemas/Rut" }
        data:
          type: object
          properties:
            object:
              description: The full current resource, identical to what GET would return.
              type: object
              additionalProperties: true
            previous_attributes:
              type: object
              description: Present on updates; holds only what changed.
              additionalProperties: true
            detail:
              type: object
              description: |
                The SUB-OBJECT this event is actually about, when `object` is a wider resource
                (#414). `empresa.onboarding.updated` always carries the full onboarding body as
                `object` — never a mandato, a go-live authorization, or a certification file, the
                four shapes it used to arrive in — and the thing that changed rides here instead.
                `empresa.onboarding.step.completed` uses it to carry `{ "step": "<step_key>" }`.

                NOT the same idea as `previous_attributes`: that says what a field on `object`
                USED TO BE; this is a different object entirely.
              additionalProperties: true

    # ---- usage & jobs

    UsageMetric:
      type: string
      enum: [document_accepted, received_document, f29_filing, empresa_active]

    UsageSummary:
      type: object
      properties:
        object: { type: string, const: usage_summary }
        period: { $ref: "#/components/schemas/Period" }
        as_of: { type: string, format: date-time }
        totals:
          type: object
          additionalProperties: { type: integer }
        by_empresa:
          type: array
          items:
            type: object
            properties:
              empresa:
                type: object
                properties:
                  id: { type: string }
                  rut: { $ref: "#/components/schemas/Rut" }
              documents_accepted: { type: integer }
              by_tipo_dte:
                type: object
                additionalProperties: { type: integer }
      # No `plan` object, and none coming: the plan is flat and unlimited, its
      # price is public, and restating it on every usage read would be a second
      # place for it to go stale. `GET /usage` has never served this key.

    UsageRecord:
      type: object
      properties:
        object: { type: string, const: usage_record }
        id: { type: string }
        metric: { $ref: "#/components/schemas/UsageMetric" }
        quantity: { type: integer }
        empresa:
          type: object
          properties:
            id: { type: string }
            rut: { $ref: "#/components/schemas/Rut" }
        document_id: { type: [string, "null"] }
        occurred_at:
          type: string
          format: date-time
          description: For `document_accepted`, the moment the SII accepted it. That is the moment it enters the ledger.
        billing_period: { $ref: "#/components/schemas/Period" }
        reversal_of:
          type: [string, "null"]
          description: Corrections are compensating records with negative quantity, never updates.

    Job:
      type: object
      description: |
        One shape for every asynchronous operation the API starts. They differ in what they do and
        not at all in how you watch them, which is the point of giving them one object and one
        endpoint (`GET /jobs/{job_id}`).

        `type` decides two things a caller needs: which scope may read the job (the scope that
        STARTS that kind of job, plus the read scope for the subject it acts on), and the shape of
        `result`.
      properties:
        object: { type: string, const: job }
        id: { type: string }
        type:
          type: string
          description: |
            The closed set of job types this API writes. Enumerated here rather than left open
            because a generated client validates against it; `test/agent-contract.test.ts` reads
            every `type` literal written into `sii_v1_jobs` across `src/` and `worker/src/` and
            fails if one is missing from this list, so the enum cannot drift from the writers.
          enum:
            - folio_request
            - document_send
            - cesion_submit
            - rcv_sync
            - statement_import
            - connection_sync
        status: { type: string, enum: [queued, running, succeeded, failed] }
        mode:
          $ref: "#/components/schemas/Mode"
          description: |
            The world this job belongs to (ADR-0004: sandbox-vs-real, unambiguous on every
            response). A job created in the other mode does not EXIST for your credential — the
            answer is `404`, not `403`, exactly as it is on every list.
        empresa_id: { type: [string, "null"] }
        tipo_dte: { type: [integer, "null"] }
        quantity: { type: [integer, "null"] }
        result: { $ref: "#/components/schemas/JobResult" }
        error: { $ref: "#/components/schemas/ErrorBody" }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    JobResult:
      description: |
        What the job produced. **The shape is a function of the job's `type`**, so branch on `type`
        and read `result` second — the same rule `error.details` follows for `code`.

        `{}` while the job is `queued` or `running`, and for a failure whose executor recorded
        nothing beyond `error`. §5.8 applies: a job type added later brings a shape your client does
        not know, and that is never a fault.
      oneOf:
        - title: pending
          description: Nothing recorded yet — queued, running, or a failure carrying only an `error`.
          type: object
          maxProperties: 0
        - title: folio_request
          description: |
            The range the SII actually granted. `quantity` here is the size REGISTERED, not the
            `quantity` asked for: the driver clamps to the SII's own per-tipo ceiling when the ask
            is above it, and the registered range is the only authoritative answer.
          type: object
          required: [tipo_dte, quantity, from, to]
          properties:
            tipo_dte: { $ref: "#/components/schemas/TipoDte" }
            quantity: { type: integer }
            caf_id:
              type: [string, "null"]
              description: '`null` in the sandbox, where folios are virtual and no CAF is fetched.'
            from: { type: integer }
            to: { type: integer }
            note: { type: string }
        - title: folio_request (failed)
          description: |
            `range_registered` is the fact that decides whether a retry is safe: once a range IS
            registered a second attempt would assign ANOTHER one and walk the empresa toward the
            SII's unused-range limit.
          type: object
          required: [range_registered]
          properties:
            range_registered: { type: boolean }
        - title: document_send
          type: object
          required: [delivered, to]
          properties:
            delivered: { type: boolean }
            simulated:
              type: boolean
              description: Test mode — nothing reached a real mailbox (§4.2).
            provider: { type: string }
            provider_message_id: { type: [string, "null"] }
            to: { type: array, items: { type: string, format: email } }
            attach: { type: array, items: { type: string, enum: [pdf, xml] } }
        - title: cesion_submit
          description: |
            How far the irreversible act got. `status: submitted` with `registration: pending` is the
            furthest the upload itself can reach — a receipt is not a registration (ticket #69) — and
            the job stays `running` until the registry's own `getEstEnvio` settles it. `rtc_track_id`
            is the SII's receipt number for the envelope, and is what the SII's own «Consulta
            Resultado de Envíos» page takes.
          type: object
          required: [step_reached]
          properties:
            status: { type: string, examples: ["submitted"] }
            step_reached: { type: string }
            registration: { type: string, enum: [pending, confirmed] }
            rtc_track_id: { type: [string, "null"], examples: ["0000038936"] }
        - title: cesion_submit (fenced)
          description: The live-cesión fence refused the attempt before anything was posted.
          type: object
          required: [fenced]
          properties:
            fenced: { type: boolean }
        - title: rcv_sync
          description: |
            What one period's read found. `purchases` and `sales` are the documents the SII's
            REGISTRO held for the period (created, updated or unchanged in the mirror); `created` and
            `updated` are how many of those moved this time. `withdrawn_at_sii` counts documents the
            register stopped returning (excluded from the F29 until they come back). `partial_errors`
            names any sub-registry the SII refused — the read still landed what it could.
            While the job is `queued` or `running` only `period` is present.
          type: object
          required: [period]
          properties:
            period: { $ref: "#/components/schemas/Period" }
            purchases: { type: integer }
            sales: { type: integer }
            created: { type: integer }
            updated: { type: integer }
            changed: { type: boolean }
            withdrawn_at_sii: { type: integer }
            restored_at_sii: { type: integer }
            partial_errors: { type: array, items: { type: string } }
            simulated: { type: boolean }
        - title: statement_import
          type: object
          required: [statement_id]
          properties:
            statement_id: { type: string }
            filename: { type: string }
            format: { type: string }
        - title: connection_sync
          type: object
          required: [connection_id]
          properties:
            connection_id: { type: string }
            imported: { type: integer }
            simulated: { type: boolean }

    SendJob:
      title: SendJob
      description: |
        The job `POST /documents/{document_id}/send` returns. It is a `Job` of type `document_send`
        plus the three fields that say WHAT was sent WHERE — they are on the response and were not
        on `Job`, so a strict client dropped exactly the fields it needed to confirm the delivery
        it had just asked for.
      allOf:
        - $ref: "#/components/schemas/Job"
        - type: object
          properties:
            # `enum: [document_send]`, NOT `const: document_send`. Same one value on the wire, and
            # the same narrowing — but `const` beside the parent's `enum` is a merge no generator
            # performs: `openapi-python-client` answered «can't combine enum of type <class 'str'>
            # with Literal['document_send']» and DELETED this schema, taking the `to`/`attach`
            # confirmation of the send with it (#476). An enum narrowed to one member merges.
            type: { type: string, enum: [document_send] }
            document_id: { type: [string, "null"] }
            to:
              type: array
              items: { type: string, format: email }
              description: The resolved recipient list — the `to` you sent, or the receptor's stored addresses.
            attach:
              type: array
              items: { type: string, enum: [pdf, xml] }

    StatementImportJob:
      title: StatementImportJob
      description: |
        The job `POST /tax/statements` returns. A `Job` of type `statement_import` plus the two
        fields that name the cartola it is importing, both present on the response.
      allOf:
        - $ref: "#/components/schemas/Job"
        - type: object
          properties:
            type: { type: string, enum: [statement_import] } # see `SendJob` — never `const` here
            statement_id: { type: string }
            filename: { type: string }

    RcvSeed:
      title: RcvSeed
      description: |
        What `POST /test_helpers/rcv/seed` returns. NOT a job — the seed is written in the request,
        synchronously, because the sandbox has no worker to wait for (§4.1).
      type: object
      properties:
        object: { type: string, const: rcv_seed }
        empresa_id: { type: string }
        period: { $ref: "#/components/schemas/Period" }
        purchases: { type: integer, description: How many purchase rows were asked for. }
        sales: { type: integer, description: How many sale rows were asked for. }
        rows_written:
          type: integer
          description: |
            Rows actually written. Re-seeding the same period is idempotent — the natural key is
            stable per index, so a second call updates rather than duplicating.
