openapi: 3.1.0
info:
  title: CCA Political Finance API
  version: 0.2.0
  description: |
    Permanent local mirror of IRS Section 527 and FEC political-finance data.
    Request handlers never call a live upstream. Missing mirrored data returns an
    explicit ERROR_NOT_FOUND or ERROR_NOT_IN_MIRROR response.

    FEC contributor information may not be used to solicit contributions or for
    commercial purposes. Transaction rows preserve amendment and memo fields;
    row counts are not amendment-adjusted financial totals. Donor search returns
    matching contribution records without cross-record identity resolution.
servers:
  - url: https://politicalfinance.datasourceapi.com/api/v1
paths:
  /health:
    get:
      summary: Service health and mirror policy
      responses:
        '200': { $ref: '#/components/responses/Success' }
  /status:
    get:
      summary: Complete coverage, indexing, and legacy migration progress
      responses:
        '200': { $ref: '#/components/responses/Success' }
  /sources:
    get:
      summary: Official source catalog and mirrored cycles
      responses:
        '200': { $ref: '#/components/responses/Success' }
  /search:
    get:
      summary: Search political organizations and FEC committees in D1
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2 } }
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '400': { $ref: '#/components/responses/Error' }
  /donors/search:
    get:
      summary: Search matching FEC contribution records by donor-name token prefix
      description: |
        Searches only a versioned, bounded R2 index copied into the permanent mirror.
        The Worker performs one release-manifest read, one deterministic shard-manifest
        read, and at most two page reads. It never lists the bucket, scans full
        releases, or calls FEC/OpenFEC.

        `q` may contain multiple donor-name tokens. At least one normalized token must
        contain three characters. Optional employer, occupation, and committee-name
        filters also use case-insensitive token prefixes; committee IDs are exact.
        When one receipt has multiple name tokens matching the same prefix, only its
        lexicographically canonical posting is returned.

        An HTTP 200 response can have `data.index.status` of `indexing` or
        `not_available` with no results. This is an explicit coverage state, not a
        claim that the donor has no contribution records.
      parameters:
        - name: q
          in: query
          required: true
          description: Donor/contributor name tokens; for example, `reed`.
          schema: { type: string, minLength: 3, maxLength: 100 }
          example: reed
        - $ref: '#/components/parameters/FecCycle'
        - $ref: '#/components/parameters/Release'
        - name: state
          in: query
          schema: { type: string, pattern: '^[A-Za-z]{2}$' }
          example: NY
        - name: zip
          in: query
          description: Three- to five-digit ZIP prefix or full ZIP+4.
          schema: { type: string, pattern: '^\d{3,5}(-\d{1,4})?$' }
          example: '100'
        - name: employer
          in: query
          schema: { type: string, maxLength: 80 }
          example: acme
        - name: occupation
          in: query
          schema: { type: string, maxLength: 80 }
          example: engi
        - name: committee
          in: query
          description: Exact FEC committee ID or committee-name token prefixes.
          schema: { type: string, maxLength: 80 }
          example: C00123456
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
        - name: cursor
          in: query
          description: Opaque cursor returned by the previous response; valid only with the same query and filters.
          schema: { type: string, maxLength: 2048 }
      responses:
        '200': { $ref: '#/components/responses/DonorSearchSuccess' }
        '400': { $ref: '#/components/responses/Error' }
        '500': { $ref: '#/components/responses/Error' }
  /organization/{ein}:
    get:
      summary: Political organization by canonical formatted EIN
      parameters:
        - $ref: '#/components/parameters/Ein'
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '404': { $ref: '#/components/responses/Error' }
  /organization/{ein}/contributions:
    get:
      summary: Bounded IRS Form 8872 contribution page
      parameters:
        - $ref: '#/components/parameters/Ein'
        - $ref: '#/components/parameters/IrsCycle'
        - $ref: '#/components/parameters/Release'
        - $ref: '#/components/parameters/Part'
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '404': { $ref: '#/components/responses/Error' }
  /organization/{ein}/expenditures:
    get:
      summary: Bounded IRS Form 8872 expenditure page
      parameters:
        - $ref: '#/components/parameters/Ein'
        - $ref: '#/components/parameters/IrsCycle'
        - $ref: '#/components/parameters/Release'
        - $ref: '#/components/parameters/Part'
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '404': { $ref: '#/components/responses/Error' }
  /fec/committee/{committeeId}:
    get:
      summary: FEC committee by canonical committee ID
      description: Contributor-use restrictions are included in every response.
      parameters:
        - $ref: '#/components/parameters/CommitteeId'
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '404': { $ref: '#/components/responses/Error' }
  /fec/committee/{committeeId}/contributions:
    get:
      summary: Bounded FEC Schedule A contribution page
      description: Contributor data may not be used for solicitation or commercial purposes.
      parameters:
        - $ref: '#/components/parameters/CommitteeId'
        - $ref: '#/components/parameters/FecCycle'
        - $ref: '#/components/parameters/Release'
        - $ref: '#/components/parameters/Part'
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '404': { $ref: '#/components/responses/Error' }
  /filing/{source}/{filingId}:
    get:
      summary: Filing metadata and verified mirror links
      parameters:
        - $ref: '#/components/parameters/FilingSource'
        - $ref: '#/components/parameters/FilingId'
      responses:
        '200': { $ref: '#/components/responses/Success' }
        '404': { $ref: '#/components/responses/Error' }
  /filing/{source}/{filingId}.pdf:
    get:
      summary: Stream a digest-verified immutable PDF from R2
      parameters:
        - $ref: '#/components/parameters/FilingSource'
        - $ref: '#/components/parameters/FilingId'
      responses:
        '200':
          description: Full PDF stream
          content:
            application/pdf: {}
        '206':
          description: Byte range of PDF
          content:
            application/pdf: {}
        '404': { $ref: '#/components/responses/Error' }
  /admin/ingest:
    post:
      summary: Ingest a bounded identity, filing, release, pointer, or summary batch
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [dataset, rows]
              properties:
                dataset:
                  type: string
                  enum: [political-organizations, fec-committees, identity-links, filings, fec-releases, fec-partitions, fec-summaries, irs8872-releases, irs8872-partitions, irs8872-summaries]
                rows: { type: array, minItems: 1, maxItems: 200, items: { type: object } }
      responses:
        '202': { $ref: '#/components/responses/Success' }
        '401': { $ref: '#/components/responses/Error' }
  /admin/promote:
    post:
      summary: Atomically promote a fully verified immutable release
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source, cycle, release]
              properties:
                source: { type: string, enum: [fec, irs-8872] }
                cycle: { type: string }
                release: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{6}Z$' }
      responses:
        '202': { $ref: '#/components/responses/Success' }
        '409': { $ref: '#/components/responses/Error' }
  /admin/archive/register:
    post:
      summary: Reserve a create-once permanent R2 key
      description: Safe first-PR control plane. Bytes are uploaded by an external multipart pipeline; no deletion route exists.
      security: [{ AdminKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bucket, object_key, object_size, sha256]
              properties:
                bucket: { type: string, enum: [raw, parsed, snapshots] }
                object_key: { type: string }
                object_size: { type: integer, minimum: 0 }
                sha256: { type: string, pattern: '^[a-f0-9]{64}$' }
                content_type: { type: string }
      responses:
        '202': { $ref: '#/components/responses/Success' }
        '409': { $ref: '#/components/responses/Error' }
  /admin/archive/complete:
    post:
      summary: Verify size and SHA-256 metadata and complete a reserved object
      security: [{ AdminKey: [] }]
      responses:
        '202': { $ref: '#/components/responses/Success' }
        '409': { $ref: '#/components/responses/Error' }
  /admin/migration/run:
    post:
      summary: Resume one bounded legacy migration batch
      description: Copies only complete source objects and bounded D1 rows. Safe to retry.
      security: [{ AdminKey: [] }]
      responses:
        '202': { $ref: '#/components/responses/Success' }
        '409': { $ref: '#/components/responses/Error' }
  /admin/migration/catchup:
    post:
      summary: Reset cursors for an idempotent catch-up pass
      security: [{ AdminKey: [] }]
      responses:
        '202': { $ref: '#/components/responses/Success' }
  /admin/migration/status:
    post:
      summary: Authenticated migration checkpoint status
      security: [{ AdminKey: [] }]
      responses:
        '202': { $ref: '#/components/responses/Success' }
  /admin/migration/parity:
    post:
      summary: Compare source and destination object, row, and release counts
      security: [{ AdminKey: [] }]
      responses:
        '202': { $ref: '#/components/responses/Success' }
components:
  securitySchemes:
    AdminKey:
      type: apiKey
      in: header
      name: X-Admin-Key
  parameters:
    Ein:
      name: ein
      in: path
      required: true
      schema: { type: string, pattern: '^\d{2}-\d{7}$' }
    CommitteeId:
      name: committeeId
      in: path
      required: true
      schema: { type: string, pattern: '^C\d{8}$' }
    IrsCycle:
      name: cycle
      in: query
      schema: { type: string, pattern: '^\d{4}-\d{2}$' }
    FecCycle:
      name: cycle
      in: query
      schema: { type: string, pattern: '^\d{4}$' }
    Release:
      name: release
      in: query
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{6}Z$' }
    Part:
      name: part
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    FilingSource:
      name: source
      in: path
      required: true
      schema: { type: string, enum: [irs-8871, irs-8872, irs-990, fec] }
    FilingId:
      name: filingId
      in: path
      required: true
      schema: { type: string }
  schemas:
    Meta:
      type: object
      required: [sources, cache, upstream, cycle, release, response_time_ms]
      properties:
        sources: { type: array, items: { type: string } }
        cache: { type: object }
        upstream:
          type: object
          properties:
            status: { const: skipped }
        cycle: { type: [string, 'null'] }
        release: { type: [string, 'null'] }
        response_time_ms: { type: integer }
    Envelope:
      type: object
      required: [request_status, message, data, meta]
      properties:
        request_status: { type: string, enum: [success, error] }
        message: { type: string }
        error_code: { type: string }
        data: {}
        meta: { $ref: '#/components/schemas/Meta' }
    DonorIndexState:
      type: object
      required: [status, message, cycle, release, record_count, index_entry_count, shard_count, page_count]
      properties:
        status: { type: string, enum: [available, indexing, not_available] }
        message: { type: string }
        cycle: { type: [string, 'null'] }
        release: { type: [string, 'null'] }
        record_count: { type: integer, minimum: 0 }
        index_entry_count: { type: integer, minimum: 0 }
        shard_count: { type: integer, minimum: 0 }
        page_count: { type: integer, minimum: 0 }
    DonorContributionRecord:
      type: object
      required:
        - result_type
        - transaction_id
        - contributor_name
        - recipient_committee_id
        - cycle
        - release
        - source_url
      properties:
        result_type: { const: matching_contribution_record }
        transaction_id: { type: string }
        contributor_name: { type: string }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        zip: { type: [string, 'null'] }
        employer: { type: [string, 'null'] }
        occupation: { type: [string, 'null'] }
        amount:
          type: [string, 'null']
          description: Preserved amount for this contribution row; never an additive donor total.
        transaction_date: { type: [string, 'null'], format: date }
        recipient_committee_name: { type: [string, 'null'] }
        recipient_committee_id: { type: string, pattern: '^C\d{8}$' }
        cycle: { type: string, pattern: '^\d{4}$' }
        release: { type: string }
        amendment_indicator: { type: [string, 'null'] }
        memo_code: { type: [string, 'null'] }
        memo_text: { type: [string, 'null'] }
        memoed_subtotal: { type: [boolean, 'null'] }
        filing_id: { type: [string, 'null'] }
        image_number: { type: [string, 'null'] }
        source_part: { type: [integer, 'null'], minimum: 1 }
        source_url:
          type: string
          description: Local mirrored committee contribution page; never a live upstream link.
        filing_url:
          type: [string, 'null']
          description: Local mirrored filing metadata link when the filing ID is available.
    DonorSearchData:
      type: object
      required:
        - query
        - result_label
        - count
        - results
        - pagination
        - index
        - contributor_use_restriction
        - amendment_notice
        - identity_notice
      properties:
        query: { type: string }
        result_label: { const: Matching contribution records }
        count: { type: integer, minimum: 0, maximum: 50 }
        results:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/DonorContributionRecord' }
        pagination:
          type: object
          required: [limit, next_cursor]
          properties:
            limit: { type: integer, minimum: 1, maximum: 50 }
            next_cursor: { type: [string, 'null'] }
        index: { $ref: '#/components/schemas/DonorIndexState' }
        reads:
          type: object
          description: Present when the index is available.
          properties:
            r2_objects: { type: integer, minimum: 1, maximum: 4 }
            maximum: { const: 4 }
        contributor_use_restriction: { type: string }
        amendment_notice: { type: string }
        identity_notice: { type: string }
  responses:
    Success:
      description: Fleet success envelope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
    Error:
      description: Fleet error envelope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Envelope' }
          examples:
            validation:
              summary: Invalid donor query
              value:
                request_status: error
                message: Provide q with a donor-name token of at least 3 characters.
                error_code: ERROR_VALIDATION
                data: null
                meta:
                  sources: [cca-politicalfinance]
                  cache: { status: miss, layer: worker }
                  upstream: { status: skipped }
                  cycle: null
                  release: null
                  response_time_ms: 1
            integrity:
              summary: Mirrored index object failed its bounded contract
              value:
                request_status: error
                message: The donor-index manifest failed its release contract.
                error_code: ERROR_INTEGRITY
                data: null
                meta:
                  sources: [cca-politicalfinance]
                  cache: { status: miss, layer: worker }
                  upstream: { status: skipped }
                  cycle: null
                  release: null
                  response_time_ms: 2
    DonorSearchSuccess:
      description: Fleet success envelope containing matching contribution records or an explicit index coverage state.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Envelope'
              - type: object
                properties:
                  data: { $ref: '#/components/schemas/DonorSearchData' }
          examples:
            matches:
              summary: Matching contribution records
              value:
                request_status: success
                message: OK
                data:
                  query: reed
                  result_label: Matching contribution records
                  count: 1
                  results:
                    - result_type: matching_contribution_record
                      transaction_id: '4070120262012345678'
                      contributor_name: REED, ALICE
                      city: NEW YORK
                      state: NY
                      zip: '10001'
                      employer: ACME CO
                      occupation: ENGINEER
                      amount: '125.00'
                      transaction_date: '2026-05-01'
                      recipient_committee_name: EXAMPLE COMMITTEE
                      recipient_committee_id: C00123456
                      cycle: '2026'
                      release: 2026-08-02T090000Z
                      amendment_indicator: A
                      memo_code: X
                      memo_text: AMENDED RECORD
                      memoed_subtotal: true
                      filing_id: '1234567'
                      image_number: '202608020001'
                      source_part: 7
                      source_url: /api/v1/fec/committee/C00123456/contributions?cycle=2026&release=2026-08-02T090000Z&part=7
                      filing_url: /api/v1/filing/fec/1234567
                  pagination:
                    limit: 25
                    next_cursor: eyJ2ZXJzaW9uIjoxLCIuLi4iOiIuLi4ifQ
                  index:
                    status: available
                    message: The bounded donor-name index is available from permanent mirrored R2 objects.
                    cycle: '2026'
                    release: 2026-08-02T090000Z
                    record_count: 25000000
                    index_entry_count: 43000000
                    shard_count: 12000
                    page_count: 43000
                  reads: { r2_objects: 2, maximum: 4 }
                  contributor_use_restriction: FEC contributor information may not be used to solicit contributions or for commercial purposes.
                  amendment_notice: Rows preserve amendment and memo indicators. No additive donor totals are calculated.
                  identity_notice: Each result is a matching contribution record; the mirror does not resolve people across records.
                meta:
                  sources: [fec-schedule-a, fec-donor-index]
                  cache: { status: miss, layer: worker }
                  upstream: { status: skipped }
                  cycle: '2026'
                  release: 2026-08-02T090000Z
                  response_time_ms: 8
            indexing:
              summary: Index not yet public
              value:
                request_status: success
                message: OK
                data:
                  query: reed
                  result_label: Matching contribution records
                  count: 0
                  results: []
                  pagination: { limit: 25, next_cursor: null }
                  index:
                    status: indexing
                    message: The donor-name index is being built or migrated and is not public yet.
                    cycle: '2026'
                    release: 2026-08-02T090000Z
                    record_count: 25000000
                    index_entry_count: 0
                    shard_count: 0
                    page_count: 0
                  contributor_use_restriction: FEC contributor information may not be used to solicit contributions or for commercial purposes.
                  amendment_notice: Rows preserve amendment and memo indicators. No additive donor totals are calculated.
                  identity_notice: Each result is a matching contribution record; the mirror does not resolve people across records.
                meta:
                  sources: [fec-schedule-a, fec-donor-index]
                  cache: { status: miss, layer: worker }
                  upstream: { status: skipped }
                  cycle: '2026'
                  release: 2026-08-02T090000Z
                  response_time_ms: 2
